Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 31 additions & 9 deletions src/parseOperations.mts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<XData, ThrowOnError>`).
* 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,
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 4 additions & 15 deletions src/tsmorph/buildCommon.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -100,15 +101,8 @@ export function buildMutationKeyConst(
* Example: export const UseFindPetsKeyFn = (clientOptions: Options<FindPetsData, true> = {}, queryKey?: Array<unknown>) =>
* [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 ? " = {}" : "";
Expand Down Expand Up @@ -158,17 +152,12 @@ export function buildMutationKeyFn(
* Example:
* export type FindPaginatedPetsInfiniteClientOptions = Omit<Options<FindPaginatedPetsData, true>, "query"> &
* { query?: Omit<NonNullable<FindPaginatedPetsData["query"]>, "page"> };
*
* The `<Method>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<Options<${dataTypeName}, true>, "query"> & { query?: Omit<NonNullable<${dataTypeName}["query"]>, "${ctx.pageParam}"> }`;

return {
Expand Down
24 changes: 2 additions & 22 deletions src/tsmorph/buildMutationHooks.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<TData>`;

Expand Down
55 changes: 9 additions & 46 deletions src/tsmorph/buildQueryHooks.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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 `<Method>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
Expand Down Expand Up @@ -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`;

Expand Down Expand Up @@ -239,7 +206,7 @@ export function buildUseSuspenseQueryHook(
const hookName = `use${op.capitalizedMethodName}Suspense`;
const errorType = getErrorType(op, ctx);
const dataTypeDefault = `NonNullable<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`;

Expand Down Expand Up @@ -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)`;
Expand All @@ -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}`,
},
],
};
Expand Down Expand Up @@ -428,7 +392,6 @@ export function buildPrefetchInfiniteQueryFn(
*/
export function buildEnsureQueryDataFn(
op: OperationInfo,
ctx: GenerationContext,
): VariableStatementStructure {
const fnName = `ensureUse${op.capitalizedMethodName}Data`;

Expand All @@ -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}`,
},
],
};
Expand Down
3 changes: 1 addition & 2 deletions src/tsmorph/buildQueryOptions.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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} })`;
Expand Down
8 changes: 4 additions & 4 deletions src/tsmorph/generateFiles.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 35 additions & 0 deletions src/tsmorph/operationNames.mts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 14 additions & 0 deletions src/types.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<XData, ThrowOnError>` 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) */
Expand Down
Loading
Loading