From 9426b01bda5904aab23e8fcceff7dab062844f69 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:33:11 +0000 Subject: [PATCH 1/2] feat: name enum query parameter types Generate a type for each enum-valued query parameter, named Query, e.g. ListOrganizationTraceItemAttributesQueryDataset. Array parameters name their element type. These enums are inlined in each Data type, so consumers had to derive them with NonNullable<...['query']>[...]. Co-Authored-By: BYK --- README.md | 14 ++++ build.mjs | 2 + scripts/generate-query-enum-types.mjs | 99 +++++++++++++++++++++++++++ test/typecheck.ts | 13 ++++ 4 files changed, 128 insertions(+) create mode 100644 scripts/generate-query-enum-types.mjs diff --git a/README.md b/README.md index 49813834..e2d86c1c 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,20 @@ const { data, error } = await listOrganizations({ They can also reuse an existing instance's configuration: `listOrganizations({ client: sentry.client })`. +### Query parameter types + +Each operation's `Data` type describes its `path`, `query`, and `body`. Enum-valued query parameters also get a named type for one value, `Query`. For array parameters, that is the element type: + +```ts +import type { + ListOrganizationTraceItemAttributesQueryAttributeType, + ListOrganizationTraceItemAttributesQueryDataset, +} from "@sentry/api"; + +const dataset: ListOrganizationTraceItemAttributesQueryDataset = "spans"; +const types: ListOrganizationTraceItemAttributesQueryAttributeType[] = ["string", "number"]; +``` + ### Browser sessions The browser helper configures session cookies and CSRF token injection: diff --git a/build.mjs b/build.mjs index 41972ccd..7a600002 100644 --- a/build.mjs +++ b/build.mjs @@ -50,6 +50,7 @@ execSync(`node ${JSON.stringify(join(__dirname, "scripts", "generate-response-co // is documented as in-development and unstable. execSync(`node ${JSON.stringify(join(__dirname, "scripts", "generate-pagination.mjs"))}`, { stdio: "inherit" }); execSync(`node ${JSON.stringify(join(__dirname, "scripts", "generate-error-results.mjs"))}`, { stdio: "inherit" }); +execSync(`node ${JSON.stringify(join(__dirname, "scripts", "generate-query-enum-types.mjs"))}`, { stdio: "inherit" }); execSync(`node ${JSON.stringify(join(__dirname, "scripts", "constrain-throw-options.mjs"))}`, { stdio: "inherit" }); execSync(`node ${JSON.stringify(join(__dirname, "scripts", "generate-client.mjs"))}`, { stdio: "inherit" }); @@ -64,6 +65,7 @@ appendFileSync( "export { parseSentryLinkHeader, unwrapResult, unwrapPaginatedResult, fetchPage, paginateAll, paginateUpTo } from './sentry-pagination.ts';", "export type { UnwrappedResult, PaginatedResponse, PaginatedCollection, PaginateAllOptions, PaginateUpToOptions, PageFetcher, BudgetedPageFetcher } from './sentry-pagination.ts';", "export * from './error-results.gen.ts';", + "export type * from './query-enums.gen.ts';", "export * from './pagination.gen.ts';", "export { createSentryClient } from './sentry-client.gen.ts';", "export type { SentryClient } from './sentry-client.gen.ts';", diff --git a/scripts/generate-query-enum-types.mjs b/scripts/generate-query-enum-types.mjs new file mode 100644 index 00000000..17e79e4c --- /dev/null +++ b/scripts/generate-query-enum-types.mjs @@ -0,0 +1,99 @@ +/** + * Name the value type of every enum-valued query parameter, e.g. + * `ListOrganizationTraceItemAttributesQueryDataset` for the `dataset` query + * parameter of `listOrganizationTraceItemAttributes`. The generator inlines + * these enums in each operation's `Data` type, so consumers otherwise have to + * dig them out with `NonNullable<…['query']>['dataset']`. + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const normalizedSpec = join(root, "openapi-normalized.json"); +const specPath = existsSync(normalizedSpec) + ? normalizedSpec + : join(root, "openapi-derefed.json"); +const spec = JSON.parse(readFileSync(specPath, "utf8")); +const sdkSource = readFileSync(join(root, "src", "sdk.gen.ts"), "utf8"); +const typesSource = readFileSync(join(root, "src", "types.gen.ts"), "utf8"); + +const existingTypes = new Set( + [...typesSource.matchAll(/^export type (\w+)\b/gm)].map((match) => match[1]), +); + +/** `attributeType`, `data_source`, `stats-period` → `AttributeType`, `DataSource`, `StatsPeriod` */ +const pascal = (name) => + name + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(""); + +/** Enum query parameters per route, with whether the parameter is an array. */ +const enumParams = new Map(); +for (const [path, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(methods)) { + if (typeof operation !== "object" || operation === null) continue; + const params = []; + for (const param of operation.parameters ?? []) { + if (param.in !== "query") continue; + const schema = param.schema ?? {}; + if (Array.isArray(schema.enum)) { + params.push({ name: param.name, array: false }); + } else if (schema.type === "array" && Array.isArray(schema.items?.enum)) { + params.push({ name: param.name, array: true }); + } + } + if (params.length > 0) { + enumParams.set(`${method.toUpperCase()} ${path}`, params); + } + } +} + +const aliases = []; +const seen = new Set(); +for (const block of sdkSource.split(/^export const /m).slice(1)) { + const functionMatch = block.match(/^(\w+) =/); + const typeMatch = block.match(/Options<(\w+)Data,/); + const urlMatch = block.match(/url: '([^']+)'/); + const methodMatch = block.match(/\)\.(\w+) a.alias.localeCompare(b.alias)); + +const typeBases = [...new Set(aliases.map(({ typeBase }) => `${typeBase}Data`))].sort(); +const lines = [ + "// This file is auto-generated by scripts/generate-query-enum-types.mjs", + "// Do not edit directly. Re-run `bun run build` to regenerate.", + "", + "import type {", + ...typeBases.map((typeName) => ` ${typeName},`), + "} from './types.gen';", + "", +]; +for (const { alias, array, fn, name, typeBase } of aliases) { + const value = `NonNullable[${JSON.stringify(name)}]>`; + lines.push( + `/** A value of the \`${name}\` query parameter of \`${fn}\`. */`, + `export type ${alias} = ${array ? `${value}[number]` : value};`, + "", + ); +} + +writeFileSync(join(root, "src", "query-enums.gen.ts"), lines.join("\n")); +console.log(`generate-query-enum-types: ${aliases.length} query enum types`); diff --git a/test/typecheck.ts b/test/typecheck.ts index 4d72761a..9bc11a2b 100644 --- a/test/typecheck.ts +++ b/test/typecheck.ts @@ -21,6 +21,10 @@ import { paginateAll_listOrganizationProjects, paginateUpTo_listOrganizationIssues, } from "../src/index"; +import type { + ListOrganizationTraceItemAttributesQueryAttributeType, + ListOrganizationTraceItemAttributesQueryDataset, +} from "../src/index"; import type { InferOutput } from "valibot"; import type { z } from "zod"; import { vAutofixPostResponse } from "../src/valibot"; @@ -224,3 +228,12 @@ async function statusNarrowsErrorBody() { void narrowErrorHappyPath; void statusNarrowsErrorBody; + +// Enum query parameters are named, and array parameters name their element. +const attributeDataset: ListOrganizationTraceItemAttributesQueryDataset = "tracemetrics"; +// @ts-expect-error "errors" is not a trace item dataset. +const invalidAttributeDataset: ListOrganizationTraceItemAttributesQueryDataset = "errors"; +const attributeType: ListOrganizationTraceItemAttributesQueryAttributeType = "number"; +// @ts-expect-error The alias is the element type, not the array. +const attributeTypeList: ListOrganizationTraceItemAttributesQueryAttributeType = ["number"]; +void [attributeDataset, invalidAttributeDataset, attributeType, attributeTypeList]; From 11f24b57d7974da3342b11669eef2b2fbcb8d2b3 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:47:44 +0000 Subject: [PATCH 2/2] ref: parse the generated SDK with the TypeScript compiler API Read operations and their Data types from the sdk.gen.ts and types.gen.ts ASTs instead of matching source text with regexes, like generate-client.mjs and constrain-throw-options.mjs do. Fail the build when an operation has an unexpected shape or a spec enum parameter is missing from its Data['query'] type, instead of skipping it silently. --- scripts/generate-query-enum-types.mjs | 101 ++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 16 deletions(-) diff --git a/scripts/generate-query-enum-types.mjs b/scripts/generate-query-enum-types.mjs index 17e79e4c..34c79452 100644 --- a/scripts/generate-query-enum-types.mjs +++ b/scripts/generate-query-enum-types.mjs @@ -9,6 +9,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import ts from "typescript"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const normalizedSpec = join(root, "openapi-normalized.json"); @@ -16,12 +17,82 @@ const specPath = existsSync(normalizedSpec) ? normalizedSpec : join(root, "openapi-derefed.json"); const spec = JSON.parse(readFileSync(specPath, "utf8")); -const sdkSource = readFileSync(join(root, "src", "sdk.gen.ts"), "utf8"); -const typesSource = readFileSync(join(root, "src", "types.gen.ts"), "utf8"); -const existingTypes = new Set( - [...typesSource.matchAll(/^export type (\w+)\b/gm)].map((match) => match[1]), -); +const parse = (name) => { + const path = join(root, "src", name); + return ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true); +}; +const sdk = parse("sdk.gen.ts"); +const types = parse("types.gen.ts"); + +/** Top-level type declarations in types.gen.ts, by name. */ +const typeDeclarations = new Map(); +for (const statement of types.statements) { + if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) { + typeDeclarations.set(statement.name.text, statement); + } +} + +const propertyName = (member, source) => + ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(source); + +/** Property names of `Data['query']`. */ +const queryProperties = (dataType) => { + const declaration = typeDeclarations.get(dataType); + const literal = declaration && ts.isTypeAliasDeclaration(declaration) && declaration.type; + if (!literal || !ts.isTypeLiteralNode(literal)) return new Set(); + const query = literal.members.find((member) => + ts.isPropertySignature(member) && propertyName(member, types) === "query"); + if (!query?.type || !ts.isTypeLiteralNode(query.type)) return new Set(); + return new Set( + query.type.members + .filter((member) => ts.isPropertySignature(member)) + .map((member) => propertyName(member, types)), + ); +}; + +/** + * The `Options<…>` reference in an operation's parameter type. It is the type + * itself for `(options: Options<…>)`, or nested in a tuple for operations whose + * options are optional: `(...[options]: [options?: Options<…>] & …)`. + */ +const findOptions = (node) => { + if (ts.isTypeReferenceNode(node) && node.typeName.getText(sdk) === "Options") return node; + return ts.forEachChild(node, findOptions); +}; + +/** + * Each SDK operation's name, `Data` type, HTTP method and URL, read + * from `export const op = (options: Options) => (…).get<…>({ url: '…', … })`. + */ +const operations = []; +for (const statement of sdk.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + const fn = declaration.initializer; + if (!ts.isIdentifier(declaration.name) || !fn || !ts.isArrowFunction(fn)) continue; + + const optionsType = fn.parameters[0]?.type && findOptions(fn.parameters[0].type); + const dataType = optionsType?.typeArguments?.[0]; + const call = ts.isCallExpression(fn.body) && fn.body; + const callee = call && call.expression; + const request = call && call.arguments[0]; + const url = request && ts.isObjectLiteralExpression(request) && request.properties.find((property) => + ts.isPropertyAssignment(property) && propertyName(property, sdk) === "url"); + if ( + !dataType || !ts.isTypeReferenceNode(dataType) || !ts.isIdentifier(dataType.typeName) || + !callee || !ts.isPropertyAccessExpression(callee) || + !url || !ts.isStringLiteralLike(url.initializer) + ) { + throw new Error(`generate-query-enum-types: unsupported SDK operation ${declaration.name.text}`); + } + operations.push({ + fn: declaration.name.text, + dataType: dataType.typeName.text, + route: `${callee.name.text.toUpperCase()} ${url.initializer.text}`, + }); + } +} /** `attributeType`, `data_source`, `stats-period` → `AttributeType`, `DataSource`, `StatsPeriod` */ const pascal = (name) => @@ -54,24 +125,22 @@ for (const [path, methods] of Object.entries(spec.paths ?? {})) { const aliases = []; const seen = new Set(); -for (const block of sdkSource.split(/^export const /m).slice(1)) { - const functionMatch = block.match(/^(\w+) =/); - const typeMatch = block.match(/Options<(\w+)Data,/); - const urlMatch = block.match(/url: '([^']+)'/); - const methodMatch = block.match(/\)\.(\w+) a.alias.localeCompare(b.alias));