diff --git a/src/parseOperations.mts b/src/parseOperations.mts index e5d8971..277842a 100644 --- a/src/parseOperations.mts +++ b/src/parseOperations.mts @@ -1,4 +1,9 @@ -import type { Project, VariableDeclaration } from "ts-morph"; +import { + Node, + type ParameterDeclaration, + type Project, + type VariableDeclaration, +} from "ts-morph"; import ts from "typescript"; import { capitalizeFirstLetter, @@ -68,8 +73,26 @@ function extractParameters( } /** - * Get paginatable methods by checking if their Data type has the pageParam in query property. + * Read the operation's Data type name from the first type argument of the + * SDK options parameter (`Options`). + * See OperationInfo.dataTypeName for why the name is read from the signature + * instead of derived from the method name (#213). + */ +function getDataTypeNameFromSignature( + optionsParam: ParameterDeclaration | undefined, +): string | undefined { + const typeNode = optionsParam?.getTypeNode(); + if (!typeNode || !Node.isTypeReference(typeNode)) return undefined; + if (typeNode.getTypeName().getText() !== "Options") return undefined; + const [dataTypeArg] = typeNode.getTypeArguments(); + return dataTypeArg?.getText(); +} + +/** + * Get paginatable Data types by checking if they have the pageParam in their query property. * Uses TypeScript compiler API for accurate AST traversal. + * The map is keyed by the Data type name (e.g., "FindPetsData") so callers can + * look it up with the name read from the SDK signature. */ function getPaginatableMethods( project: Project, @@ -115,18 +138,13 @@ function getPaginatableMethods( ); if (pageParamNode) { - // Extract method name from Data type name (e.g., "FindPetsData" -> "findPets") - const methodName = key.slice(0, -4); // Remove "Data" suffix - // Convert first letter to lowercase - const methodNameLower = - methodName.charAt(0).toLowerCase() + methodName.slice(1); const pageParamType = pageParamNode.type?.getText( modelsFile.compilerNode, ); const resolvedType = typeChecker.getTypeAtLocation( pageParamNode.type ?? pageParamNode, ); - paginatableMethods.set(methodNameLower, { + paginatableMethods.set(key, { type: pageParamType ?? "unknown", typeKind: getPageParamTypeKind(resolvedType), }); @@ -157,12 +175,16 @@ export async function parseOperations( const sdkParams = getVariableArrowFunctionParameters(desc.method); const allParamsOptional = sdkParams.length === 0 || sdkParams[0].isOptional(); - const pageParamInfo = paginatableMethods.get(methodName); + const dataTypeName = getDataTypeNameFromSignature(sdkParams[0]); + const pageParamInfo = dataTypeName + ? paginatableMethods.get(dataTypeName) + : undefined; const isPaginatable = httpMethod === "GET" && pageParamInfo !== undefined; return { methodName, capitalizedMethodName: capitalizeFirstLetter(methodName), + dataTypeName, httpMethod, jsDoc: desc.jsDoc, isDeprecated: desc.isDeprecated, diff --git a/src/tsmorph/buildCommon.mts b/src/tsmorph/buildCommon.mts index 4b9877a..2539f8f 100644 --- a/src/tsmorph/buildCommon.mts +++ b/src/tsmorph/buildCommon.mts @@ -5,6 +5,7 @@ import { type VariableStatementStructure, } from "ts-morph"; import type { GenerationContext, OperationInfo } from "../types.mjs"; +import { getDataTypeName } from "./operationNames.mjs"; /** * Build the default response type alias. @@ -100,15 +101,8 @@ export function buildMutationKeyConst( * Example: export const UseFindPetsKeyFn = (clientOptions: Options = {}, queryKey?: Array) => * [useFindPetsKey, ...(queryKey ?? [clientOptions])]; */ -export function buildQueryKeyFn( - op: OperationInfo, - ctx: GenerationContext, -): VariableStatementStructure { - const dataTypeName = ctx.modelNames.includes( - `${op.capitalizedMethodName}Data`, - ) - ? `${op.capitalizedMethodName}Data` - : "unknown"; +export function buildQueryKeyFn(op: OperationInfo): VariableStatementStructure { + const dataTypeName = getDataTypeName(op); const params: string[] = []; const defaultValue = op.allParamsOptional ? " = {}" : ""; @@ -158,17 +152,12 @@ export function buildMutationKeyFn( * Example: * export type FindPaginatedPetsInfiniteClientOptions = Omit, "query"> & * { query?: Omit, "page"> }; - * - * The `Data` type is always in scope here: `parseOperations` only marks - * an operation paginatable when it found the page parameter inside that very - * type, and it discovers it through the same exported declarations that become - * `ctx.modelNames`. So there is no missing-Data case to fall back on. */ export function buildInfiniteClientOptionsType( op: OperationInfo, ctx: GenerationContext, ): TypeAliasDeclarationStructure { - const dataTypeName = `${op.capitalizedMethodName}Data`; + const dataTypeName = getDataTypeName(op); const type = `Omit, "query"> & { query?: Omit, "${ctx.pageParam}"> }`; return { diff --git a/src/tsmorph/buildMutationHooks.mts b/src/tsmorph/buildMutationHooks.mts index 489b973..b867e8c 100644 --- a/src/tsmorph/buildMutationHooks.mts +++ b/src/tsmorph/buildMutationHooks.mts @@ -5,21 +5,7 @@ import { } from "ts-morph"; import type { GenerationContext, OperationInfo } from "../types.mjs"; import { SDK_CALL_ARGS } from "./buildQueryHooks.mjs"; - -/** - * Get the error type string based on client type. - */ -function getErrorType(op: OperationInfo, ctx: GenerationContext): string { - const errorTypeName = `${op.capitalizedMethodName}Error`; - // Operations without error responses have no generated Error type - const errorType = ctx.modelNames.includes(errorTypeName) - ? errorTypeName - : "unknown"; - if (ctx.client === "@hey-api/client-axios") { - return `AxiosError<${errorType}>`; - } - return errorType; -} +import { getDataTypeName, getErrorType } from "./operationNames.mjs"; /** * Build useMutation hook. @@ -41,13 +27,7 @@ export function buildUseMutationHook( const errorType = getErrorType(op, ctx); const dataTypeDefault = `Common.${op.capitalizedMethodName}MutationResult`; - const dataTypeName = ctx.modelNames.includes( - `${op.capitalizedMethodName}Data`, - ) - ? `${op.capitalizedMethodName}Data` - : "unknown"; - - const optionsType = `Options<${dataTypeName}, true>`; + const optionsType = `Options<${getDataTypeName(op)}, true>`; const mutationFn = `clientOptions => ${op.methodName}(${SDK_CALL_ARGS}) as unknown as Promise`; diff --git a/src/tsmorph/buildQueryHooks.mts b/src/tsmorph/buildQueryHooks.mts index 24e766e..65f96e2 100644 --- a/src/tsmorph/buildQueryHooks.mts +++ b/src/tsmorph/buildQueryHooks.mts @@ -4,34 +4,7 @@ import { type VariableStatementStructure, } from "ts-morph"; import type { GenerationContext, OperationInfo } from "../types.mjs"; - -/** - * Get the error type string based on client type. - */ -function getErrorType(op: OperationInfo, ctx: GenerationContext): string { - const errorTypeName = `${op.capitalizedMethodName}Error`; - // Operations without error responses have no generated Error type - const errorType = ctx.modelNames.includes(errorTypeName) - ? errorTypeName - : "unknown"; - if (ctx.client === "@hey-api/client-axios") { - return `AxiosError<${errorType}>`; - } - return errorType; -} - -/** - * Resolve the generated Data type name for an operation, falling back to - * unknown when the operation has no generated Data type. - */ -export function getDataTypeName( - op: OperationInfo, - ctx: GenerationContext, -): string { - return ctx.modelNames.includes(`${op.capitalizedMethodName}Data`) - ? `${op.capitalizedMethodName}Data` - : "unknown"; -} +import { getDataTypeName, getErrorType } from "./operationNames.mjs"; /** * SDK call arguments shared by every generated queryFn/mutationFn. @@ -53,11 +26,8 @@ export function getPageParamType(op: OperationInfo): string { /** * Build the client options parameter string. */ -export function buildClientOptionsParam( - op: OperationInfo, - ctx: GenerationContext, -): string { - const dataTypeName = getDataTypeName(op, ctx); +export function buildClientOptionsParam(op: OperationInfo): string { + const dataTypeName = getDataTypeName(op); const hasParams = op.parameters.length > 0; if (!hasParams) { @@ -94,15 +64,12 @@ export function getPageType(op: OperationInfo): string { * TQueryFnData the infinite options are instantiated with (#203). Spelled as * a cast rather than `!` because generated code is linted downstream, and a * non-null assertion trips biome's noNonNullAssertion. - * - * Only ever called for paginatable operations, so the `Data` type is - * guaranteed to exist — see buildInfiniteClientOptionsType for why. */ export function buildPagedQueryFn( op: OperationInfo, ctx: GenerationContext, ): string { - const dataTypeName = `${op.capitalizedMethodName}Data`; + const dataTypeName = getDataTypeName(op); const thenClause = `.then(response => response.data as ${getPageType(op)})`; const pageParamType = getPageParamType(op); // When the initial page param is omitted, the first request must send no @@ -208,7 +175,7 @@ export function buildUseQueryHook( const hookName = `use${op.capitalizedMethodName}`; const errorType = getErrorType(op, ctx); const dataTypeDefault = `Common.${op.capitalizedMethodName}DefaultResponse`; - const clientOptionsParam = buildClientOptionsParam(op, ctx); + const clientOptionsParam = buildClientOptionsParam(op); const queryFn = `({ signal }) => ${op.methodName}(${QUERY_SDK_CALL_ARGS}).then(response => response.data as TData) as TData`; @@ -239,7 +206,7 @@ export function buildUseSuspenseQueryHook( const hookName = `use${op.capitalizedMethodName}Suspense`; const errorType = getErrorType(op, ctx); const dataTypeDefault = `NonNullable`; - const clientOptionsParam = buildClientOptionsParam(op, ctx); + const clientOptionsParam = buildClientOptionsParam(op); const queryFn = `({ signal }) => ${op.methodName}(${QUERY_SDK_CALL_ARGS}).then(response => response.data as TData) as TData`; @@ -339,10 +306,7 @@ export function buildUseSuspenseInfiniteQueryHook( * ...options * }); */ -export function buildPrefetchFn( - op: OperationInfo, - ctx: GenerationContext, -): VariableStatementStructure { +export function buildPrefetchFn(op: OperationInfo): VariableStatementStructure { const fnName = `prefetchUse${op.capitalizedMethodName}`; const queryFn = `({ signal }) => ${op.methodName}(${QUERY_SDK_CALL_ARGS}).then(response => response.data)`; @@ -359,7 +323,7 @@ export function buildPrefetchFn( declarations: [ { name: fnName, - initializer: `(queryClient: QueryClient, ${buildClientOptionsParam(op, ctx)}, ${optionsParam}) => ${body}`, + initializer: `(queryClient: QueryClient, ${buildClientOptionsParam(op)}, ${optionsParam}) => ${body}`, }, ], }; @@ -428,7 +392,6 @@ export function buildPrefetchInfiniteQueryFn( */ export function buildEnsureQueryDataFn( op: OperationInfo, - ctx: GenerationContext, ): VariableStatementStructure { const fnName = `ensureUse${op.capitalizedMethodName}Data`; @@ -446,7 +409,7 @@ export function buildEnsureQueryDataFn( declarations: [ { name: fnName, - initializer: `(queryClient: QueryClient, ${buildClientOptionsParam(op, ctx)}, ${optionsParam}) => ${body}`, + initializer: `(queryClient: QueryClient, ${buildClientOptionsParam(op)}, ${optionsParam}) => ${body}`, }, ], }; diff --git a/src/tsmorph/buildQueryOptions.mts b/src/tsmorph/buildQueryOptions.mts index 98052cd..c2ce04e 100644 --- a/src/tsmorph/buildQueryOptions.mts +++ b/src/tsmorph/buildQueryOptions.mts @@ -28,10 +28,9 @@ import { */ export function buildQueryOptionsFn( op: OperationInfo, - ctx: GenerationContext, ): VariableStatementStructure { const fnName = `${op.methodName}Options`; - const clientOptionsParam = buildClientOptionsParam(op, ctx); + const clientOptionsParam = buildClientOptionsParam(op); const queryFn = `({ signal }) => ${op.methodName}(${QUERY_SDK_CALL_ARGS}).then(response => response.data)`; const body = `queryOptions({ queryKey: Common.Use${op.capitalizedMethodName}KeyFn(clientOptions, queryKey), queryFn: ${queryFn} })`; diff --git a/src/tsmorph/generateFiles.mts b/src/tsmorph/generateFiles.mts index fbaf85c..bf52ed7 100644 --- a/src/tsmorph/generateFiles.mts +++ b/src/tsmorph/generateFiles.mts @@ -93,7 +93,7 @@ function generateCommonFile( sourceFile.addTypeAlias(buildDefaultResponseType(op)); sourceFile.addTypeAlias(buildQueryResultType(op)); sourceFile.addVariableStatement(buildQueryKeyConst(op)); - sourceFile.addVariableStatement(buildQueryKeyFn(op, ctx)); + sourceFile.addVariableStatement(buildQueryKeyFn(op)); } // Add dedicated infinite query types and keys for paginatable operations @@ -170,7 +170,7 @@ function generateQueryOptionsFile( const getOperations = operations.filter((op) => op.httpMethod === "GET"); for (const op of getOperations) { - sourceFile.addVariableStatement(buildQueryOptionsFn(op, ctx)); + sourceFile.addVariableStatement(buildQueryOptionsFn(op)); } // Add infiniteQueryOptions factories @@ -264,7 +264,7 @@ function generatePrefetchFile( // Add prefetch functions for (const op of getOperations) { - sourceFile.addVariableStatement(buildPrefetchFn(op, ctx)); + sourceFile.addVariableStatement(buildPrefetchFn(op)); } // Add prefetchInfiniteQuery functions @@ -297,7 +297,7 @@ function generateEnsureQueryDataFile( // Add ensureQueryData functions for (const op of getOperations) { - sourceFile.addVariableStatement(buildEnsureQueryDataFn(op, ctx)); + sourceFile.addVariableStatement(buildEnsureQueryDataFn(op)); } return sourceFile.getFullText(); diff --git a/src/tsmorph/operationNames.mts b/src/tsmorph/operationNames.mts new file mode 100644 index 0000000..5325ff9 --- /dev/null +++ b/src/tsmorph/operationNames.mts @@ -0,0 +1,35 @@ +import type { GenerationContext, OperationInfo } from "../types.mjs"; + +/** + * Resolve the generated Data type name for an operation, falling back to + * unknown when the SDK signature exposes no Data type. + * See OperationInfo.dataTypeName for why the name is read from the signature + * instead of derived from the method name (#213). + */ +export function getDataTypeName(op: OperationInfo): string { + return op.dataTypeName ?? "unknown"; +} + +/** + * Get the error type string based on client type. + * The Error type shares its stem with the Data type — hey-api mints both from + * the operationId (see OperationInfo.dataTypeName, #213) — so the stem comes + * from `dataTypeName` rather than the method name. The modelNames probe stays + * because operations without error responses have no generated Error type. + */ +export function getErrorType( + op: OperationInfo, + ctx: GenerationContext, +): string { + const stem = op.dataTypeName + ? op.dataTypeName.replace(/Data$/, "") + : op.capitalizedMethodName; + const errorTypeName = `${stem}Error`; + const errorType = ctx.modelNames.includes(errorTypeName) + ? errorTypeName + : "unknown"; + if (ctx.client === "@hey-api/client-axios") { + return `AxiosError<${errorType}>`; + } + return errorType; +} diff --git a/src/types.mts b/src/types.mts index f10457a..acda82c 100644 --- a/src/types.mts +++ b/src/types.mts @@ -7,6 +7,20 @@ export interface OperationInfo { methodName: string; /** Capitalized method name (e.g., "FindPets") */ capitalizedMethodName: string; + /** + * Generated Data type name read from the SDK function's own + * `Options` signature (e.g., "FindPetsData"). + * Not derivable from the method name: for digit-leading operationIds + * hey-api prefixes the function but strips the digits from the type (#213). + * Undefined when the SDK signature exposes no Data type. + * + * The rule this encodes: names hey-api owns (Data, Error) must be read from + * the SDK signature or share this field's stem; names this codegen mints + * itself (DefaultResponse, MutationResult, key fns) may be derived from + * capitalizedMethodName because they anchor back to the SDK via + * `typeof methodName`. + */ + dataTypeName?: string; /** HTTP method (e.g., "GET", "POST", "PUT", "PATCH", "DELETE") */ httpMethod: string; /** JSDoc comment string (if present) */ diff --git a/tests/createSource.test.ts b/tests/createSource.test.ts index 5b0e022..e12b487 100644 --- a/tests/createSource.test.ts +++ b/tests/createSource.test.ts @@ -1,5 +1,10 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { createClient } from "@hey-api/openapi-ts"; +import ts from "typescript"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { createSource } from "../src/createSource.mjs"; +import { formatOutput } from "../src/format.mjs"; import { cleanOutputs, generateTSClients, outputPath } from "./utils"; const fileName = "createSource"; @@ -100,3 +105,73 @@ describe(fileName, () => { ); }); }); + +// End-to-end pin for #213: hey-api names the SDK function `_123NumericLead` +// but the Data type `NumericLeadData`. The full pipeline must still detect +// pagination and produce output that typechecks — the bug's two symptoms were +// a silently missing infinite hook and `Options` failing TS2344. +describe("createSource - digit-leading operationId (#213)", () => { + const prefix = "createSource-digit-leading"; + const dir = outputPath(prefix); + + beforeAll(async () => { + await createClient({ + input: path.join(__dirname, "inputs", "digit-leading.yaml"), + output: path.join(dir, "requests"), + plugins: ["@hey-api/client-fetch", "@hey-api/typescript", "@hey-api/sdk"], + }); + }); + afterAll(async () => await cleanOutputs(prefix)); + + test("emits infinite hooks and output that typechecks", async () => { + const source = await createSource({ + outputPath: path.join(dir, "requests"), + version: "1.0.0", + pageParam: "page", + nextPageParam: "nextPage", + initialPageParam: "1", + omitInitialPageParam: false, + client: "@hey-api/client-fetch", + }); + + const infiniteQueriesTs = source.find( + (s) => s.name === "infiniteQueries.ts", + ); + expect(infiniteQueriesTs?.content).toContain("use_123NumericLeadInfinite"); + expect(infiniteQueriesTs?.content).toContain( + "Options", + ); + + const queriesDir = path.join(dir, "queries"); + await mkdir(queriesDir, { recursive: true }); + await Promise.all( + source.map((file) => + writeFile(path.join(queriesDir, file.name), file.content), + ), + ); + // The real pipeline organizes imports after printing (generate.mts), + // which dedupes the Options import shared by the client and service + // import declarations — compile what users actually get. + await formatOutput(queriesDir); + + const program = ts.createProgram( + source.map((file) => path.join(queriesDir, file.name)), + { + strict: true, + noEmit: true, + skipLibCheck: true, + esModuleInterop: true, + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }, + ); + const diagnostics = ts + .getPreEmitDiagnostics(program) + .map( + (d) => + `${d.file?.fileName ?? ""}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`, + ); + expect(diagnostics).toEqual([]); + }); +}); diff --git a/tests/inputs/digit-leading.yaml b/tests/inputs/digit-leading.yaml new file mode 100644 index 0000000..374005c --- /dev/null +++ b/tests/inputs/digit-leading.yaml @@ -0,0 +1,60 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Digit-leading operationId + description: > + Regression spec for #213. hey-api names the SDK function `_123NumericLead` + (identifiers cannot start with a digit) but names the Data type + `NumericLeadData` (digits are dropped), so the two names diverge. +paths: + /d: + get: + operationId: 123numericLead + parameters: + - name: page + in: query + schema: + type: integer + responses: + "200": + description: ok + content: + application/json: + schema: + type: array + items: + type: string + "400": + description: bad request + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + post: + operationId: 456createThing + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + "201": + description: created + "400": + description: bad request + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" +components: + schemas: + ApiError: + type: object + required: + - message + properties: + message: + type: string diff --git a/tests/parseOperations.test.ts b/tests/parseOperations.test.ts index 1eee9f7..7ea2c15 100644 --- a/tests/parseOperations.test.ts +++ b/tests/parseOperations.test.ts @@ -26,6 +26,8 @@ describe("parseOperations", () => { expect(findPets).toBeDefined(); expect(findPets?.httpMethod).toBe("GET"); expect(findPets?.capitalizedMethodName).toBe("FindPets"); + // Read from the SDK signature's Options type argument + expect(findPets?.dataTypeName).toBe("FindPetsData"); }); it("should parse POST operations", async () => { @@ -70,13 +72,8 @@ describe("parseOperations", () => { expect(findPaginatedPets?.pageParamTypeKind).toBe("number"); }); - // The infinite-query builders spell the Data type as `${capitalizedMethodName}Data` - // with no fallback, because `getPaginatableMethods` only marks an operation - // paginatable after finding the page parameter inside that very type, and it reads - // the same exported declarations that become `modelNames`. That coupling is what - // makes the fallback unnecessary, so it is pinned here rather than left implicit: - // resolving Data types by any other route must keep this invariant or the - // generated code will reference a type that does not exist. + // Pins the invariant the infinite-query builders rely on: every paginatable + // operation has a dataTypeName that exists among the exported model names. it("should expose a Data type in modelNames for every paginatable operation", async () => { const project = new Project({ skipAddingFilesFromTsConfig: true }); project.addSourceFilesAtPaths(`${outputPath(fileName)}/**/*`); @@ -96,7 +93,7 @@ describe("parseOperations", () => { expect(paginatable.length).toBeGreaterThan(0); for (const op of paginatable) { - expect(ctx.modelNames).toContain(`${op.capitalizedMethodName}Data`); + expect(ctx.modelNames).toContain(op.dataTypeName); } }); @@ -248,6 +245,57 @@ describe("parseOperations", () => { }); }); +describe("parseOperations - digit-leading operationId (#213)", () => { + const digitFileName = "parseOperations-digit-leading"; + + beforeAll( + async () => await generateTSClients(digitFileName, "digit-leading.yaml"), + ); + afterAll(async () => await cleanOutputs(digitFileName)); + + // hey-api names the SDK function `_123NumericLead` but the Data type + // `NumericLeadData`, so deriving the type from the method name misses. + // The name must come from the SDK signature for pagination to be detected + // and for the generated output to compile. + it("should resolve the Data type from the SDK signature and keep pagination", async () => { + const project = new Project({ skipAddingFilesFromTsConfig: true }); + project.addSourceFilesAtPaths(`${outputPath(digitFileName)}/**/*`); + + const operations = await parseOperations(project, "page"); + const op = operations.find((o) => o.httpMethod === "GET"); + + expect(op).toBeDefined(); + expect(op?.methodName).toBe("_123NumericLead"); + expect(op?.dataTypeName).toBe("NumericLeadData"); + expect(op?.isPaginatable).toBe(true); + expect(op?.pageParamTypeKind).toBe("number"); + + const postOp = operations.find((o) => o.httpMethod === "POST"); + expect(postOp?.methodName).toBe("_456CreateThing"); + expect(postOp?.dataTypeName).toBe("CreateThingData"); + }); + + // Pins the naming assumption getErrorType's stem derivation relies on: + // hey-api mints the Error type from the same stem as the Data type. + it("should generate Error types sharing the Data type stem", async () => { + const project = new Project({ skipAddingFilesFromTsConfig: true }); + project.addSourceFilesAtPaths(`${outputPath(digitFileName)}/**/*`); + + const ctx = buildGenerationContext( + project, + "@hey-api/client-fetch", + "page", + "nextPage", + "1", + false, + "1.0.0", + ); + + expect(ctx.modelNames).toContain("NumericLeadError"); + expect(ctx.modelNames).toContain("CreateThingError"); + }); +}); + describe("parseOperations - string pagination", () => { const stringFileName = "parseOperations-string-pagination"; diff --git a/tests/tsmorph/buildCommon.test.ts b/tests/tsmorph/buildCommon.test.ts index 4508631..d33f975 100644 --- a/tests/tsmorph/buildCommon.test.ts +++ b/tests/tsmorph/buildCommon.test.ts @@ -17,6 +17,7 @@ import type { GenerationContext, OperationInfo } from "../../src/types.mjs"; const mockOperation: OperationInfo = { methodName: "findPets", capitalizedMethodName: "FindPets", + dataTypeName: "FindPetsData", httpMethod: "GET", isDeprecated: false, parameters: [{ name: "limit", typeName: "number", optional: true }], @@ -27,6 +28,7 @@ const mockOperation: OperationInfo = { const mockMutationOperation: OperationInfo = { methodName: "addPet", capitalizedMethodName: "AddPet", + dataTypeName: "AddPetData", httpMethod: "POST", isDeprecated: false, parameters: [{ name: "body", typeName: "NewPet", optional: false }], @@ -37,6 +39,7 @@ const mockMutationOperation: OperationInfo = { const mockPaginatableOperation: OperationInfo = { methodName: "findPaginatedPets", capitalizedMethodName: "FindPaginatedPets", + dataTypeName: "FindPaginatedPetsData", httpMethod: "GET", isDeprecated: false, parameters: [{ name: "page", typeName: "number", optional: true }], @@ -46,7 +49,7 @@ const mockPaginatableOperation: OperationInfo = { const mockContext: GenerationContext = { client: "@hey-api/client-fetch", - modelNames: ["Pet", "NewPet", "FindPetsData", "AddPetData"], + modelNames: ["Pet", "NewPet"], serviceNames: ["findPets", "addPet"], pageParam: "page", nextPageParam: "nextPage", @@ -136,7 +139,7 @@ describe("buildCommon", () => { describe("buildQueryKeyFn", () => { it("should build query key function with parameters", () => { - const result = buildQueryKeyFn(mockOperation, mockContext); + const result = buildQueryKeyFn(mockOperation); expect(result.kind).toBe(StructureKind.VariableStatement); expect(result.isExported).toBe(true); @@ -157,15 +160,12 @@ describe("buildCommon", () => { ...mockOperation, methodName: "getPetById", capitalizedMethodName: "GetPetById", + dataTypeName: "GetPetByIdData", parameters: [{ name: "id", typeName: "number", optional: false }], allParamsOptional: false, }; - const ctx: GenerationContext = { - ...mockContext, - modelNames: [...mockContext.modelNames, "GetPetByIdData"], - }; - const result = buildQueryKeyFn(op, ctx); + const result = buildQueryKeyFn(op); const initializer = result.declarations[0].initializer as string; expect(initializer).toContain( @@ -174,18 +174,35 @@ describe("buildCommon", () => { expect(initializer).not.toContain("= {}"); }); - it("should use unknown for missing data type", () => { + it("should fall back to unknown when the SDK signature exposes no Data type", () => { const op: OperationInfo = { ...mockOperation, methodName: "unknownMethod", capitalizedMethodName: "UnknownMethod", + dataTypeName: undefined, }; - const result = buildQueryKeyFn(op, mockContext); + const result = buildQueryKeyFn(op); const initializer = result.declarations[0].initializer as string; expect(initializer).toContain("Options"); }); + + it("should use the signature Data type for digit-leading operationIds (#213)", () => { + const op: OperationInfo = { + ...mockOperation, + methodName: "_123NumericLead", + capitalizedMethodName: "_123NumericLead", + dataTypeName: "NumericLeadData", + }; + + const result = buildQueryKeyFn(op); + const initializer = result.declarations[0].initializer as string; + + expect(initializer).toContain( + "clientOptions: Options", + ); + }); }); describe("buildMutationKeyFn", () => { @@ -204,13 +221,9 @@ describe("buildCommon", () => { }); describe("buildInfiniteClientOptionsType", () => { it("should exclude the page param from the query type", () => { - const ctx: GenerationContext = { - ...mockContext, - modelNames: [...mockContext.modelNames, "FindPaginatedPetsData"], - }; const result = buildInfiniteClientOptionsType( mockPaginatableOperation, - ctx, + mockContext, ); expect(result.isExported).toBe(true); diff --git a/tests/tsmorph/buildMutationHooks.test.ts b/tests/tsmorph/buildMutationHooks.test.ts index 1566a8b..7d4beef 100644 --- a/tests/tsmorph/buildMutationHooks.test.ts +++ b/tests/tsmorph/buildMutationHooks.test.ts @@ -6,6 +6,7 @@ import type { GenerationContext, OperationInfo } from "../../src/types.mjs"; const mockPostOperation: OperationInfo = { methodName: "addPet", capitalizedMethodName: "AddPet", + dataTypeName: "AddPetData", httpMethod: "POST", isDeprecated: false, parameters: [{ name: "body", typeName: "NewPet", optional: false }], @@ -16,6 +17,7 @@ const mockPostOperation: OperationInfo = { const mockDeleteOperation: OperationInfo = { methodName: "deletePet", capitalizedMethodName: "DeletePet", + dataTypeName: "DeletePetData", httpMethod: "DELETE", isDeprecated: false, parameters: [{ name: "id", typeName: "number", optional: false }], @@ -26,6 +28,7 @@ const mockDeleteOperation: OperationInfo = { const mockPutOperation: OperationInfo = { methodName: "updatePet", capitalizedMethodName: "UpdatePet", + dataTypeName: "UpdatePetData", httpMethod: "PUT", isDeprecated: false, parameters: [ @@ -39,6 +42,7 @@ const mockPutOperation: OperationInfo = { const mockPatchOperation: OperationInfo = { methodName: "patchPet", capitalizedMethodName: "PatchPet", + dataTypeName: "PatchPetData", httpMethod: "PATCH", isDeprecated: false, parameters: [{ name: "body", typeName: "Partial", optional: true }], @@ -51,13 +55,9 @@ const mockFetchContext: GenerationContext = { modelNames: [ "Pet", "NewPet", - "AddPetData", "AddPetError", - "DeletePetData", "DeletePetError", - "UpdatePetData", "UpdatePetError", - "PatchPetData", "PatchPetError", ], serviceNames: ["addPet", "deletePet", "updatePet", "patchPet"], @@ -174,21 +174,38 @@ describe("buildMutationHooks", () => { ); }); - it("should use unknown for missing data type", () => { + it("should fall back to unknown when the SDK signature exposes no Data type", () => { const opWithoutData: OperationInfo = { ...mockPostOperation, methodName: "unknownMutation", capitalizedMethodName: "UnknownMutation", + dataTypeName: undefined, + }; + + const result = buildUseMutationHook(opWithoutData, mockFetchContext); + const initializer = result.declarations[0].initializer as string; + + expect(initializer).toContain("Options"); + }); + + it("should use the signature Data type for digit-leading operationIds (#213)", () => { + const op: OperationInfo = { + ...mockPostOperation, + methodName: "_123CreateThing", + capitalizedMethodName: "_123CreateThing", + dataTypeName: "CreateThingData", }; const ctx: GenerationContext = { ...mockFetchContext, - modelNames: ["Pet", "NewPet"], // no UnknownMutationData + modelNames: [...mockFetchContext.modelNames, "CreateThingError"], }; - const result = buildUseMutationHook(opWithoutData, ctx); + const result = buildUseMutationHook(op, ctx); const initializer = result.declarations[0].initializer as string; - expect(initializer).toContain("Options"); + expect(initializer).toContain("Options"); + // The Error type shares the Data type stem, not the method name + expect(initializer).toContain("TError = CreateThingError"); }); it("should spread options at the end", () => { diff --git a/tests/tsmorph/buildQueryHooks.test.ts b/tests/tsmorph/buildQueryHooks.test.ts index a8afcb8..cfeaedf 100644 --- a/tests/tsmorph/buildQueryHooks.test.ts +++ b/tests/tsmorph/buildQueryHooks.test.ts @@ -17,6 +17,7 @@ import type { GenerationContext, OperationInfo } from "../../src/types.mjs"; const mockOperation: OperationInfo = { methodName: "findPets", capitalizedMethodName: "FindPets", + dataTypeName: "FindPetsData", httpMethod: "GET", isDeprecated: false, parameters: [{ name: "limit", typeName: "number", optional: true }], @@ -27,6 +28,7 @@ const mockOperation: OperationInfo = { const mockPaginatableOperation: OperationInfo = { methodName: "findPaginatedPets", capitalizedMethodName: "FindPaginatedPets", + dataTypeName: "FindPaginatedPetsData", httpMethod: "GET", isDeprecated: false, parameters: [{ name: "page", typeName: "number", optional: true }], @@ -45,6 +47,7 @@ const mockStringPaginatableOperation: OperationInfo = { const mockRequiredParamsOperation: OperationInfo = { methodName: "findPetById", capitalizedMethodName: "FindPetById", + dataTypeName: "FindPetByIdData", httpMethod: "GET", isDeprecated: false, parameters: [{ name: "id", typeName: "number", optional: false }], @@ -52,7 +55,8 @@ const mockRequiredParamsOperation: OperationInfo = { isPaginatable: false, }; -const mockNoParamsOperation: OperationInfo = { +// No dataTypeName: the SDK signature exposed no Options parameter +const mockNoDataTypeOperation: OperationInfo = { methodName: "getStatus", capitalizedMethodName: "GetStatus", httpMethod: "GET", @@ -62,15 +66,21 @@ const mockNoParamsOperation: OperationInfo = { isPaginatable: false, }; +// hey-api prefixes a digit-leading operationId in the function name but strips +// the digits from the Data type, so the two names diverge (#213) +const mockDigitLeadingOperation: OperationInfo = { + ...mockPaginatableOperation, + methodName: "_123NumericLead", + capitalizedMethodName: "_123NumericLead", + dataTypeName: "NumericLeadData", +}; + const mockFetchContext: GenerationContext = { client: "@hey-api/client-fetch", modelNames: [ "Pet", - "FindPetsData", "FindPetsError", - "FindPaginatedPetsData", "FindPaginatedPetsError", - "FindPetByIdData", "FindPetByIdError", ], serviceNames: ["findPets", "findPaginatedPets", "findPetById"], @@ -85,11 +95,6 @@ const mockAxiosContext: GenerationContext = { client: "@hey-api/client-axios", }; -const mockUnknownDataContext: GenerationContext = { - ...mockFetchContext, - modelNames: [], -}; - describe("buildQueryHooks", () => { describe("infinite query helpers", () => { it("should omit the default value when a paginatable op has required params", () => { @@ -163,10 +168,10 @@ describe("buildQueryHooks", () => { expect(initializer).not.toContain("= {}"); }); - it("should handle operations without params and unknown data type", () => { + it("should fall back to unknown when the SDK signature exposes no Data type", () => { const result = buildUseQueryHook( - mockNoParamsOperation, - mockUnknownDataContext, + mockNoDataTypeOperation, + mockFetchContext, ); const initializer = result.declarations[0].initializer as string; @@ -177,6 +182,21 @@ describe("buildQueryHooks", () => { "getStatus({ ...clientOptions, signal, throwOnError: true })", ); }); + + it("should use the signature Data type for digit-leading operationIds (#213)", () => { + const ctx: GenerationContext = { + ...mockFetchContext, + modelNames: [...mockFetchContext.modelNames, "NumericLeadError"], + }; + const result = buildUseQueryHook(mockDigitLeadingOperation, ctx); + const initializer = result.declarations[0].initializer as string; + + expect(initializer).toContain( + "clientOptions: Options = {}", + ); + // The Error type shares the Data type stem, not the method name + expect(initializer).toContain("TError = NumericLeadError"); + }); }); describe("buildUseSuspenseQueryHook", () => { @@ -202,10 +222,10 @@ describe("buildQueryHooks", () => { ); }); - it("should handle operations without params and unknown data type", () => { + it("should fall back to unknown when the SDK signature exposes no Data type", () => { const result = buildUseSuspenseQueryHook( - mockNoParamsOperation, - mockUnknownDataContext, + mockNoDataTypeOperation, + mockFetchContext, ); const initializer = result.declarations[0].initializer as string; @@ -244,6 +264,17 @@ describe("buildQueryHooks", () => { expect(initializer).toContain("initialPageParam: 1"); }); + it("should cast to the signature Data type for digit-leading operationIds (#213)", () => { + const result = buildUseInfiniteQueryHook( + mockDigitLeadingOperation, + mockFetchContext, + ); + + expect(result).not.toBeNull(); + const initializer = result?.declarations[0].initializer as string; + expect(initializer).toContain("as Options"); + }); + it("should make initialPageParam and getNextPageParam optional overrides", () => { const result = buildUseInfiniteQueryHook( mockPaginatableOperation, @@ -314,7 +345,7 @@ describe("buildQueryHooks", () => { describe("buildPrefetchFn", () => { it("should build prefetch function", () => { - const result = buildPrefetchFn(mockOperation, mockFetchContext); + const result = buildPrefetchFn(mockOperation); expect(result.declarations[0].name).toBe("prefetchUseFindPets"); @@ -333,17 +364,14 @@ describe("buildQueryHooks", () => { }); it("should include default value for optional params", () => { - const result = buildPrefetchFn(mockOperation, mockFetchContext); + const result = buildPrefetchFn(mockOperation); const initializer = result.declarations[0].initializer as string; expect(initializer).toContain("= {}"); }); it("should not include default value for required params", () => { - const result = buildPrefetchFn( - mockRequiredParamsOperation, - mockFetchContext, - ); + const result = buildPrefetchFn(mockRequiredParamsOperation); const initializer = result.declarations[0].initializer as string; expect(initializer).toContain( @@ -355,7 +383,7 @@ describe("buildQueryHooks", () => { }); it("should accept fetch query options (#157)", () => { - const result = buildPrefetchFn(mockOperation, mockFetchContext); + const result = buildPrefetchFn(mockOperation); const initializer = result.declarations[0].initializer as string; expect(initializer).toContain( @@ -364,11 +392,8 @@ describe("buildQueryHooks", () => { expect(initializer).toMatch(/\.\.\.options\s*\}\)/); }); - it("should handle operations without params and unknown data type", () => { - const result = buildPrefetchFn( - mockNoParamsOperation, - mockUnknownDataContext, - ); + it("should fall back to unknown when the SDK signature exposes no Data type", () => { + const result = buildPrefetchFn(mockNoDataTypeOperation); const initializer = result.declarations[0].initializer as string; expect(initializer).toContain( @@ -382,7 +407,7 @@ describe("buildQueryHooks", () => { describe("buildEnsureQueryDataFn", () => { it("should build ensureQueryData function", () => { - const result = buildEnsureQueryDataFn(mockOperation, mockFetchContext); + const result = buildEnsureQueryDataFn(mockOperation); expect(result.declarations[0].name).toBe("ensureUseFindPetsData"); @@ -393,11 +418,8 @@ describe("buildQueryHooks", () => { }); it("should be similar to prefetch but use ensureQueryData", () => { - const prefetchResult = buildPrefetchFn(mockOperation, mockFetchContext); - const ensureResult = buildEnsureQueryDataFn( - mockOperation, - mockFetchContext, - ); + const prefetchResult = buildPrefetchFn(mockOperation); + const ensureResult = buildEnsureQueryDataFn(mockOperation); const prefetchInit = prefetchResult.declarations[0].initializer as string; const ensureInit = ensureResult.declarations[0].initializer as string; @@ -407,11 +429,8 @@ describe("buildQueryHooks", () => { expect(ensureInit).not.toContain("prefetchQuery"); }); - it("should handle operations without params and unknown data type", () => { - const result = buildEnsureQueryDataFn( - mockNoParamsOperation, - mockUnknownDataContext, - ); + it("should fall back to unknown when the SDK signature exposes no Data type", () => { + const result = buildEnsureQueryDataFn(mockNoDataTypeOperation); const initializer = result.declarations[0].initializer as string; expect(initializer).toContain( @@ -423,7 +442,7 @@ describe("buildQueryHooks", () => { }); it("should accept ensure query data options (#157)", () => { - const result = buildEnsureQueryDataFn(mockOperation, mockFetchContext); + const result = buildEnsureQueryDataFn(mockOperation); const initializer = result.declarations[0].initializer as string; expect(initializer).toContain( diff --git a/tests/tsmorph/buildQueryOptions.test.ts b/tests/tsmorph/buildQueryOptions.test.ts index bc2a4e2..3cbe26f 100644 --- a/tests/tsmorph/buildQueryOptions.test.ts +++ b/tests/tsmorph/buildQueryOptions.test.ts @@ -9,6 +9,7 @@ import type { GenerationContext, OperationInfo } from "../../src/types.mjs"; const mockOperation: OperationInfo = { methodName: "findPets", capitalizedMethodName: "FindPets", + dataTypeName: "FindPetsData", httpMethod: "GET", isDeprecated: false, parameters: [{ name: "limit", typeName: "number", optional: true }], @@ -19,6 +20,7 @@ const mockOperation: OperationInfo = { const mockPaginatableOperation: OperationInfo = { methodName: "findPaginatedPets", capitalizedMethodName: "FindPaginatedPets", + dataTypeName: "FindPaginatedPetsData", httpMethod: "GET", isDeprecated: false, parameters: [{ name: "page", typeName: "number", optional: true }], @@ -37,6 +39,7 @@ const mockStringPaginatableOperation: OperationInfo = { const mockRequiredParamsOperation: OperationInfo = { methodName: "findPetById", capitalizedMethodName: "FindPetById", + dataTypeName: "FindPetByIdData", httpMethod: "GET", isDeprecated: false, parameters: [{ name: "id", typeName: "number", optional: false }], @@ -46,12 +49,7 @@ const mockRequiredParamsOperation: OperationInfo = { const mockContext: GenerationContext = { client: "@hey-api/client-fetch", - modelNames: [ - "Pet", - "FindPetsData", - "FindPaginatedPetsData", - "FindPetByIdData", - ], + modelNames: ["Pet"], serviceNames: ["findPets", "findPaginatedPets", "findPetById"], pageParam: "page", nextPageParam: "nextPage", @@ -62,7 +60,7 @@ const mockContext: GenerationContext = { describe("buildQueryOptions", () => { describe("buildQueryOptionsFn", () => { it("should build a queryOptions factory", () => { - const result = buildQueryOptionsFn(mockOperation, mockContext); + const result = buildQueryOptionsFn(mockOperation); expect(result.kind).toBe(StructureKind.VariableStatement); expect(result.isExported).toBe(true); @@ -82,10 +80,7 @@ describe("buildQueryOptions", () => { }); it("should not add a default value when the operation has required params", () => { - const result = buildQueryOptionsFn( - mockRequiredParamsOperation, - mockContext, - ); + const result = buildQueryOptionsFn(mockRequiredParamsOperation); const initializer = result.declarations[0].initializer as string; expect(initializer).toContain( @@ -101,7 +96,7 @@ describe("buildQueryOptions", () => { ...mockOperation, jsDoc: "/**\n * Returns all pets\n */", }; - const result = buildQueryOptionsFn(op, mockContext); + const result = buildQueryOptionsFn(op); expect(result.leadingTrivia).toBe("/**\n * Returns all pets\n */\n"); }); diff --git a/vitest.config.ts b/vitest.config.ts index 4c9fe78..55eb958 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ // instead of 15). Note that a CLI `--exclude` appends to this list rather than // replacing it, so you cannot A/B this entry from the command line. exclude: [...defaultExclude, ".claude/**"], + // Tests run real codegen (hey-api generation, TypeScript programs), which is + // multi-second work; under CI runner contention the 5s default flakes. + testTimeout: 30_000, coverage: { // Scope coverage with `include`, not `exclude`. `coverage.exclude` is matched // against absolute paths with picomatch's `contains` option, so every relative