From 26224896b9d3927795fd001f02ec03af88a741da Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 29 Oct 2026 18:16:52 +0100 Subject: [PATCH 1/2] feat(client): expose shared request preparation --- README.md | 46 ++++++ build.mjs | 1 + scripts/generate-request-preparation.mjs | 74 +++++++++ test/client-types.ts | 12 ++ test/package.test.ts | 13 ++ test/request-preparation.test.ts | 182 +++++++++++++++++++++++ 6 files changed, 328 insertions(+) create mode 100644 scripts/generate-request-preparation.mjs create mode 100644 test/request-preparation.test.ts diff --git a/README.md b/README.md index 06cac95..5837b1e 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,52 @@ const sentry = createSentryClient( const { data, error } = await sentry.listOrganizations(); ``` +### Preparing a raw request + +Use `sentry.client.prepareRequest` when a consumer needs to inspect a request +or send it through its own raw transport. It shares the preparation used by +ordinary SDK requests: instance and per-call configuration, authentication, +validation, body serialization, and request interceptors. + +```ts +const sentry = createSentryClient({ baseUrl: "https://example.test/sentry" }); +const request = await sentry.client.prepareRequest({ + method: "GET", + url: "/api/0/organizations/example/events/?field=id&field=title&cursor=old#view", + query: { cursor: "opaque-next", per_page: 50 }, +}); +// Both field values remain; the old cursor is replaced and the fragment removed. +const response = await fetch(request); +// The consumer owns status handling and reading the raw body. +``` + +Preparation does not call the configured `fetch` or parse a response. Auth +providers, validators and request interceptors still run and can have side +effects; their errors reject unchanged. The returned native `Request` snapshots +the resolved options; its signal remains linked to the caller's signal. Send it +directly with the chosen transport; calling `client.request` again would repeat preparation. Reading +`request.clone()` leaves its body available for dispatch. A custom fetch or +browser adapter can still add credentials, CSRF headers or routing at dispatch. + +For endpoints with an existing query, explicitly serialized query keys replace +all existing occurrences of those keys. Unrelated repeated parameters and their +encoding are retained, as is custom query-serializer output. With the default +serializer, `null` removes a parameter and `undefined` leaves it unchanged. +Literal URL fragments are removed; encoded `%23` values are retained. These +rules also apply to ordinary requests through the same URL builder. + +Use `bodySerializer: null` for an already serialized string, `FormData`, or +another native body. For `FormData`, also clear the default JSON content type +with `headers: { "Content-Type": null }` so `Request` supplies its boundary. +Otherwise the configured serializer still applies. Native +`Request` construction can add a content type for strings or form data; prepared +headers describe that request, not a consumer's earlier input or redacted preview. + +Pass decoded server cursors and explicit page sizes as query parameters. The SDK +does not interpret bookmark formats, select navigation directions or store +history. Existing custom transports accepted by generated operations do not +have to implement `prepareRequest`; the factory's concrete transport adds it. + ### Cancellation and custom fetch Reuse the client and supply an `AbortSignal` for each operation. The caller owns the signal, whether it comes from a command, an MCP tool call, or a query library: diff --git a/build.mjs b/build.mjs index 77a0735..f818921 100644 --- a/build.mjs +++ b/build.mjs @@ -42,6 +42,7 @@ cpSync("lib/source-analysis.ts", "src/source-analysis.ts"); // Keep the pinned Fetch generator's runtime and types aligned with our contract. execSync(`node ${JSON.stringify(join(__dirname, "scripts", "generate-response-contract.mjs"))}`, { stdio: "inherit" }); +execSync(`node ${JSON.stringify(join(__dirname, "scripts", "generate-request-preparation.mjs"))}`, { stdio: "inherit" }); // 3. Generate operation helpers and the bound client from the SDK output. // This post-processor inspects src/sdk.gen.ts and openapi-derefed.json, diff --git a/scripts/generate-request-preparation.mjs b/scripts/generate-request-preparation.mjs new file mode 100644 index 0000000..2470638 --- /dev/null +++ b/scripts/generate-request-preparation.mjs @@ -0,0 +1,74 @@ +/** Expose the pinned Fetch client's own preparation without dispatching a request. */ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +function replaceOnce(source, before, after) { + if (source.split(before).length !== 2) { + throw new Error(`Fetch generator changed: expected one match: ${before}`); + } + return source.replace(before, () => after); +} + +function update(name, transform) { + const path = fileURLToPath(new URL(`../src/${name}`, import.meta.url)); + writeFileSync(path, transform(readFileSync(path, "utf8"))); +} + +update("client/client.gen.ts", source => { + source = replaceOnce(source, + "export const createClient = (config: Config = {}): Client => {", + `/** The concrete SDK transport adds preparation without requiring it from custom Clients. */ +export type PreparedClient = Client & { + /** Prepare a native Request using configured auth, serializers and request interceptors, without calling fetch. */ + prepareRequest(options: RequestOptions & Pick, 'method'>): Promise; +}; + +export const createClient = (config: Config = {}): PreparedClient => {`); + source = replaceOnce(source, + " // The public Client signature applies operation-specific data and throw types.\n const request = async (options: RequestOptions) => {", + " const prepare = async (options: RequestOptions) => {"); + source = replaceOnce(source, + " // fetch must be assigned here, otherwise it would throw the error:", + ` return { request, opts }; + }; + + const prepareRequest = async (options: RequestOptions): Promise => + (await prepare(options)).request; + + // The public Client signature applies operation-specific data and throw types. + const request = async (options: RequestOptions) => { + const { request, opts } = await prepare(options); + + // fetch must be assigned here, otherwise it would throw the error:`); + source = replaceOnce(source, " request,\n setConfig,", " request,\n prepareRequest,\n setConfig,"); + return replaceOnce(source, " } as Client;", " } as PreparedClient;"); +}); + +update("client/index.ts", source => replaceOnce(source, + "export { createClient } from './client.gen';", + "export { createClient } from './client.gen';\nexport type { PreparedClient } from './client.gen';", +)); + +// Raw endpoints can already contain a query. Overlay explicitly serialized keys +// without re-encoding unrelated values or a custom serializer's output. +update("core/utils.gen.ts", source => replaceOnce(source, + ` if (search) { + url += \`?\${search}\`; + } + return url;`, + ` url = url.split('#', 1)[0]!; + const queryIndex = url.indexOf('?'); + const pathname = queryIndex === -1 ? url : url.slice(0, queryIndex); + const existing = queryIndex === -1 ? '' : url.slice(queryIndex + 1); + const replaced = new Set(new URLSearchParams('?' + search).keys()); + // An explicit null clears an existing parameter; undefined leaves it alone. + for (const [key, value] of Object.entries(query ?? {})) { + if (value === null) replaced.add(key); + } + const retained = existing.split('&').filter(part => { + const key = new URLSearchParams('?' + part).keys().next().value; + return part !== '' && (key === undefined || !replaced.has(key)); + }); + const combined = [...retained, search].filter(Boolean).join('&'); + return pathname + (combined ? \`?\${combined}\` : '');`, +)); diff --git a/test/client-types.ts b/test/client-types.ts index bda1c56..f363b65 100644 --- a/test/client-types.ts +++ b/test/client-types.ts @@ -31,6 +31,18 @@ function publicFactory() { return configured; } +async function preparedRequests(sentry: SentryClient) { + const request: Request = await sentry.client.prepareRequest({ + method: "GET", url: "/api/0/custom/", query: { cursor: "opaque" }, + }); + // @ts-expect-error Generic preparation requires an explicit method. + await sentry.client.prepareRequest({ url: "/api/0/custom/" }); + // Custom clients accepted by standalone operations need no new capability. + const { prepareRequest, ...legacyClient } = sentry.client; + await listOrganizations({ client: legacyClient }); + void [request, prepareRequest]; +} + async function operationArguments(sentry: SentryClient) { await sentry.listOrganizations(); await sentry.listOrganizations({ query: { owner: true }, signal: new AbortController().signal }); diff --git a/test/package.test.ts b/test/package.test.ts index 7544d66..e80947a 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -63,6 +63,18 @@ describe("package entry points", () => { assert.equal(typeof client.createSentryClient, "function"); assert.equal(typeof browser.createBrowserFetch, "function"); assert.equal("analyzeSourceApiUsage" in client, false); + const sentry = client.createSentryClient({baseUrl: "https://example.test/prefix"}); + const request = await sentry.client.prepareRequest({ + method: "POST", url: "/api/0/custom/?field=id&field=name&cursor=old#view", + query: {cursor: "opaque:next"}, body: {message: "example"}, + }); + assert.equal(request.url, "https://example.test/prefix/api/0/custom/?field=id&field=name&cursor=opaque%3Anext"); + assert.equal(request.headers.get("content-type"), "application/json"); + assert.deepEqual(await request.clone().json(), {message: "example"}); + const transport = async request => new Response(await request.text(), {status: 422}); + const response = await transport(request); + assert.equal(response.status, 422); + assert.deepEqual(await response.json(), {message: "example"}); `); execFileSync("node", ["client.mjs"], { cwd: directory }); writeFileSync(join(directory, "smoke.mjs"), ` @@ -109,6 +121,7 @@ describe("package entry points", () => { writeFileSync(join(directory, "types.ts"), ` import { consumePage, type PaginationContinuation, type PagePosition, type SentryClient } from "@sentry/api"; declare const sentry: SentryClient; + const prepared: Promise = sentry.client.prepareRequest({method: "GET", url: "/api/0/custom/"}); const position: PagePosition = {cursor: "opaque", offset: 2}; const resume: PaginationContinuation = {...position, requestBudget: 10}; const rows: string[] = consumePage({data: ["one"]}, position).data; diff --git a/test/request-preparation.test.ts b/test/request-preparation.test.ts new file mode 100644 index 0000000..6a87d06 --- /dev/null +++ b/test/request-preparation.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test"; +import { createSentryClient, type Config } from "@sentry/api"; +import { createBrowserSdkConfig } from "@sentry/api/browser"; + +const asFetch = (implementation: (request: Request) => Promise) => + ((input: RequestInfo | URL, init?: RequestInit) => + implementation(new Request(input, init))) as NonNullable; +const snapshot = async (request: Request) => { + const headers: Record = {}; + request.headers.forEach((value, name) => { headers[name] = value; }); + return { url: request.url, method: request.method, headers, body: await request.clone().text() }; +}; + +describe("request preparation", () => { + test("shares request preparation with dispatch, running configured hooks once without fetching", async () => { + const stages: string[] = []; + const requests: Request[] = []; + const transport = asFetch(async request => { + requests.push(request); + return Response.json({ accepted: true }); + }); + const sentry = createSentryClient({ + baseUrl: "https://example.test/sentry", + headers: { "X-Default": "default", "X-Override": "original" }, + auth: async () => { stages.push("auth"); return "test-token"; }, + requestValidator: async () => { stages.push("validate"); }, + bodySerializer: body => { stages.push("serialize"); return JSON.stringify(body); }, + fetch: transport, + }); + sentry.client.interceptors.request.use(request => { + stages.push("intercept"); + request.headers.set("X-Prepared", "yes"); + return request; + }); + const options = { + method: "POST" as const, + url: "/api/0/organizations/{org}/items/", + path: { org: "example" }, + body: { title: "Overview" }, + headers: { "x-override": "per-call" }, + security: [{ type: "http" as const, scheme: "bearer" as const }], + }; + const prepared = await sentry.client.prepareRequest(options); + expect(stages).toEqual(["auth", "validate", "serialize", "intercept"]); + expect(requests).toHaveLength(0); + const preview = await snapshot(prepared); + expect(prepared.headers.get("Authorization")).toBe("Bearer test-token"); + expect(prepared.headers.get("X-Default")).toBe("default"); + expect(prepared.headers.get("X-Override")).toBe("per-call"); + expect(await (await transport(prepared)).json()).toEqual({ accepted: true }); + const result = await sentry.client.request(options); + + expect(result.data).toEqual({ accepted: true }); + expect(stages).toEqual(["auth", "validate", "serialize", "intercept", "auth", "validate", "serialize", "intercept"]); + expect(await snapshot(requests[0]!)).toEqual(preview); + expect(await snapshot(requests[1]!)).toEqual(preview); + expect(options.body).toEqual({ title: "Overview" }); + const configuredHeaders = sentry.client.getConfig().headers; + if (!(configuredHeaders instanceof Headers)) throw new Error("Expected resolved headers"); + expect(configuredHeaders.get("X-Override")).toBe("original"); + }); + + test("composes with browser transport without owning its CSRF or raw response handling", async () => { + const previousFetch = globalThis.fetch; + const requests: Request[] = []; + const response = Response.json({ data: [], meta: { fields: {} } }, { status: 403 }); + globalThis.fetch = asFetch(async request => { requests.push(request); return response; }); + try { + const config = createBrowserSdkConfig({ baseUrl: "https://example.test", getCsrfToken: () => "csrf-example" }); + const sentry = createSentryClient({ ...config, fetch: asFetch(request => config.fetch(request)) }); + const prepared = await sentry.client.prepareRequest({ method: "POST", url: "/api/0/custom/", body: { value: 1 } }); + expect(requests).toHaveLength(0); + expect(prepared.headers.has("X-CSRFToken")).toBe(false); + const raw = await config.fetch(prepared); + expect(raw).toBe(response); + expect(raw.bodyUsed).toBe(false); + expect(requests[0]!.headers.get("X-CSRFToken")).toBe("csrf-example"); + expect(requests[0]!.credentials).toBe("include"); + expect(raw.status).toBe(403); + expect(await raw.json()).toEqual({ data: [], meta: { fields: {} } }); + } finally { + globalThis.fetch = previousFetch; + } + }); + + test("replaces an opaque cursor without losing repeated filters, prefixes or encoded path segments", async () => { + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test/sentry/", + fetch: asFetch(async request => { requests.push(request); return Response.json([]); }), + }); + const options = { + method: "GET" as const, + url: "/api/0/items/a%2Fb/?cursor=old&cursor=older&field=id&field=name&?cursor=unrelated&query=a%20b#ignored", + query: { cursor: "opaque/+?=#", per_page: 25 }, + }; + const request = await sentry.client.prepareRequest(options); + const url = new URL(request.url); + expect(url.pathname).toBe("/sentry/api/0/items/a%2Fb/"); + expect(url.searchParams.getAll("cursor")).toEqual(["opaque/+?=#"]); + expect(url.searchParams.get("?cursor")).toBe("unrelated"); + expect(url.searchParams.getAll("field")).toEqual(["id", "name"]); + expect(url.searchParams.get("per_page")).toBe("25"); + expect(url.search).toContain("query=a%20b"); + expect(url.hash).toBe(""); + await sentry.client.request(options); + expect(requests[0]!.url).toBe(request.url); + expect(options.url).toContain("cursor=old&cursor=older"); + }); + + test("clears a cursor for the first page while leaving unspecified query parameters intact", async () => { + const sentry = createSentryClient({ baseUrl: "https://example.test" }); + const request = await sentry.client.prepareRequest({ + method: "GET", url: "/api/0/items/?cursor=one&cursor=two&per_page=25&field=id&field=name", + query: { cursor: null, per_page: undefined }, + }); + const query = new URL(request.url).searchParams; + expect(query.has("cursor")).toBe(false); + expect(query.get("per_page")).toBe("25"); + expect(query.getAll("field")).toEqual(["id", "name"]); + }); + + test("retains a custom serializer's bytes when overlaying an existing query", async () => { + const sentry = createSentryClient({ baseUrl: "https://example.test" }); + const request = await sentry.client.prepareRequest({ + method: "GET", url: "/api/0/items/?cursor=old&field=old&%3Fcursor=old&query=a%20b", + query: { cursor: "custom/cursor value", field: ["id", "name"] }, + querySerializer: () => "??cursor=literal&cursor=custom%2fcursor+value&field=id&field=name", + }); + expect(request.url).toBe("https://example.test/api/0/items/?query=a%20b&?cursor=literal&cursor=custom%2fcursor+value&field=id&field=name"); + }); + + test("supports native raw and multipart bodies without JSON serialization", async () => { + const sentry = createSentryClient({ baseUrl: "https://example.test" }); + const raw = await sentry.client.prepareRequest({ + method: "POST", url: "/api/0/custom/", body: "raw payload", bodySerializer: null, + headers: { "Content-Type": "text/custom" }, + }); + expect(raw.headers.get("Content-Type")).toBe("text/custom"); + expect(await raw.text()).toBe("raw payload"); + const form = new FormData(); + form.append("tag", "one"); + form.append("tag", "two"); + const multipart = await sentry.client.prepareRequest({ + method: "POST", url: "/api/0/upload/", body: form, bodySerializer: null, + headers: { "Content-Type": null }, + }); + expect(multipart.headers.get("Content-Type")).toMatch(/^multipart\/form-data; boundary=/); + expect((await multipart.formData()).getAll("tag")).toEqual(["one", "two"]); + }); + + test("preserves an aborted signal and its reason without dispatching", async () => { + const controller = new AbortController(); + const reason = new Error("Caller stopped the request"); + controller.abort(reason); + let calls = 0; + const sentry = createSentryClient({ baseUrl: "https://example.test", fetch: asFetch(async () => { calls++; return new Response(); }) }); + const request = await sentry.client.prepareRequest({ method: "GET", url: "/api/0/items/", signal: controller.signal }); + expect(request.signal.aborted).toBe(true); + expect(request.signal.reason).toBe(reason); + expect(() => request.signal.throwIfAborted()).toThrow(reason); + expect(calls).toBe(0); + }); + + test("isolates concurrent clients and keeps prepared requests unchanged after reconfiguration", async () => { + const first = createSentryClient({ baseUrl: "https://first.example.test", headers: { "X-Context": "first" } }); + const second = createSentryClient({ baseUrl: "https://second.example.test", headers: { "X-Context": "second" } }); + const { prepareRequest } = first.client; + const options = { method: "POST" as const, url: "/api/0/items/", body: { value: "original" } }; + const [prepared, other] = await Promise.all([prepareRequest(options), second.client.prepareRequest(options)]); + first.client.setConfig({ baseUrl: "https://changed.example.test", headers: { "X-Context": "changed" } }); + options.body.value = "edited"; + const next = await prepareRequest(options); + + expect([prepared.url, other.url, next.url]).toEqual([ + "https://first.example.test/api/0/items/", "https://second.example.test/api/0/items/", "https://changed.example.test/api/0/items/", + ]); + expect([prepared, other, next].map(request => request.headers.get("X-Context"))).toEqual(["first", "second", "changed"]); + expect(await prepared.json()).toEqual({ value: "original" }); + expect(await next.json()).toEqual({ value: "edited" }); + }); +}); From 0797d037f13afb8190f8bd2356046782fcdb7bff Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 29 Oct 2026 21:06:06 +0100 Subject: [PATCH 2/2] docs(client): clarify prepared request overrides --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5837b1e..e55f98b 100644 --- a/README.md +++ b/README.md @@ -197,16 +197,19 @@ const response = await fetch(request); Preparation does not call the configured `fetch` or parse a response. Auth providers, validators and request interceptors still run and can have side -effects; their errors reject unchanged. The returned native `Request` snapshots -the resolved options; its signal remains linked to the caller's signal. Send it -directly with the chosen transport; calling `client.request` again would repeat preparation. Reading -`request.clone()` leaves its body available for dispatch. A custom fetch or +effects; their errors reject unchanged. The returned native `Request` includes +the resolved options and request-interceptor changes. Its signal remains linked +to the caller's unless an interceptor replaces or clears it. Send it directly +with the chosen transport; calling `client.request` again would repeat preparation. +Reading `request.clone()` leaves its body available for dispatch. A custom fetch or browser adapter can still add credentials, CSRF headers or routing at dispatch. -For endpoints with an existing query, explicitly serialized query keys replace -all existing occurrences of those keys. Unrelated repeated parameters and their -encoding are retained, as is custom query-serializer output. With the default -serializer, `null` removes a parameter and `undefined` leaves it unchanged. +For endpoints with an existing query, serialized query keys replace all existing +occurrences of those keys. Explicit `null` input keys also remove existing values, +even with custom serializers; the serialized output can then add them back. +Unrelated repeated parameters and their encoding are retained, as is custom +query-serializer output. The default serializer omits `null` and `undefined`: +`null` clears the original value, while `undefined` leaves it unchanged. Literal URL fragments are removed; encoded `%23` values are retained. These rules also apply to ordinary requests through the same URL builder.