From 664744cef41f5f7f1bb58f8c1ef0b5f9f8549ed0 Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Tue, 16 Dec 2025 21:18:52 +0900 Subject: [PATCH 1/2] feat: add TanStack Router integration with loader factories --- README.md | 70 +++++++ .../content/docs/examples/tanstack-router.md | 172 ++++++++++++++++- docs/src/content/docs/guides/introduction.mdx | 5 +- docs/src/content/docs/index.mdx | 2 +- src/constants.mts | 1 + src/tsmorph/buildRouter.mts | 130 +++++++++++++ src/tsmorph/generateFiles.mts | 90 +++++++++ src/tsmorph/index.mts | 1 + tests/__snapshots__/createSource.test.ts.snap | 64 +++++++ tests/createSource.test.ts | 9 +- tests/tsmorph/buildRouter.test.ts | 180 ++++++++++++++++++ 11 files changed, 719 insertions(+), 5 deletions(-) create mode 100644 src/tsmorph/buildRouter.mts create mode 100644 tests/tsmorph/buildRouter.test.ts diff --git a/README.md b/README.md index 652c5ea..bbd3558 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,73 @@ - Generates custom functions that use React Query's `ensureQueryData` and `prefetchQuery` functions - Generates query keys and functions for query caching - Generates pure TypeScript clients generated by [@hey-api/openapi-ts](https://github.com/hey-api/openapi-ts) +- Generates loader factories and helpers for [TanStack Router](https://tanstack.com/router) integration + +## TanStack Router Integration + +The generated `router.ts` file provides loader factories and helpers for seamless integration with TanStack Router. + +### Using Loader Factories + +Use `loaderUse*` functions in your route definitions to prefetch data: + +```tsx +// routes/pets.$petId.tsx +import { createFileRoute } from "@tanstack/react-router"; +import { loaderUseFindPetById } from "../openapi/queries/router"; +import { queryClient } from "../queryClient"; + +export const Route = createFileRoute("/pets/$petId")({ + loader: ({ params }) => + loaderUseFindPetById({ queryClient })({ + params: { petId: Number(params.petId) }, + }), + component: PetDetail, +}); +``` + +For SSR/TanStack Start, pass `queryClient` from the router context: + +```tsx +loader: ({ context, params }) => + loaderUseFindPetById({ queryClient: context.queryClient })({ + params: { petId: Number(params.petId) }, + }), +``` + +### Using withQueryPrefetch for Hover/Touch Prefetching + +The `withQueryPrefetch` helper enables prefetching on hover or touch: + +```tsx +import { withQueryPrefetch } from "../openapi/queries/router"; +import { prefetchUseFindPetById } from "../openapi/queries/prefetch"; +import { queryClient } from "../queryClient"; + +function PetLink({ petId }: { petId: number }) { + return ( + + prefetchUseFindPetById(queryClient, { path: { petId } }) + )} + > + View Pet + + ); +} +``` + +### Important Notes + +- **Router params are strings**: TanStack Router params are always strings. You must parse them to the correct type (e.g., `Number(params.petId)`) before passing to the loader. +- **External cache configuration**: When using TanStack Query as the cache, set `defaultPreloadStaleTime: 0` in your router configuration to let React Query handle cache freshness: + +```tsx +const router = createRouter({ + routeTree, + defaultPreloadStaleTime: 0, +}); +``` + +- **Link preloading**: When using `` or `defaultPreload: "intent"`, TanStack Router will automatically call the route's `loader` on hover/touch. If your loader uses `ensureUse*Data`, prefetching happens automatically without needing `withQueryPrefetch`. diff --git a/docs/src/content/docs/examples/tanstack-router.md b/docs/src/content/docs/examples/tanstack-router.md index 4bbc0c1..17f0577 100644 --- a/docs/src/content/docs/examples/tanstack-router.md +++ b/docs/src/content/docs/examples/tanstack-router.md @@ -1,6 +1,174 @@ --- title: TanStack Router Example -description: A simple example of using TanStack Router with OpenAPI React Query Codegen. +description: Using TanStack Router with OpenAPI React Query Codegen for data loading and prefetching. --- -Example of using Next.js can be found in the [`examples/tanstack-router-app`](https://github.com/7nohe/openapi-react-query-codegen/tree/main/examples/tanstack-router-app) directory of the repository. +Example of using TanStack Router can be found in the [`examples/tanstack-router-app`](https://github.com/7nohe/openapi-react-query-codegen/tree/main/examples/tanstack-router-app) directory of the repository. + +## Generated Files + +The codegen generates a `router.ts` file that provides: + +- **Loader factories** (`loaderUse*`) for route data loading +- **`withQueryPrefetch`** helper for hover/touch prefetching + +## Using Loader Factories + +Use `loaderUse*` functions in your route definitions to prefetch data before the route renders: + +```tsx +// routes/pets.$petId.tsx +import { createFileRoute } from "@tanstack/react-router"; +import { loaderUseFindPetById } from "../openapi/queries/router"; +import { queryClient } from "../queryClient"; + +export const Route = createFileRoute("/pets/$petId")({ + loader: ({ params }) => + loaderUseFindPetById({ queryClient })({ + params: { petId: Number(params.petId) }, + }), + component: PetDetail, +}); + +function PetDetail() { + const { petId } = Route.useParams(); + const { data } = useFindPetById({ path: { petId: Number(petId) } }); + + return
{data?.name}
; +} +``` + +### For SSR / TanStack Start + +When using SSR or TanStack Start, pass `queryClient` from the router context instead of importing it directly: + +```tsx +export const Route = createFileRoute("/pets/$petId")({ + loader: ({ context, params }) => + loaderUseFindPetById({ queryClient: context.queryClient })({ + params: { petId: Number(params.petId) }, + }), + component: PetDetail, +}); +``` + +### Operations Without Path Parameters + +For operations without path parameters, the loader is simpler: + +```tsx +import { loaderUseFindPets } from "../openapi/queries/router"; + +export const Route = createFileRoute("/pets")({ + loader: () => loaderUseFindPets({ queryClient })(), + component: PetList, +}); +``` + +### Passing Additional Options + +You can pass additional client options through the `clientOptions` parameter: + +```tsx +loader: ({ params }) => + loaderUseFindPetById({ + queryClient, + clientOptions: { + headers: { "X-Custom-Header": "value" }, + }, + })({ + params: { petId: Number(params.petId) }, + }), +``` + +## Using withQueryPrefetch + +The `withQueryPrefetch` helper enables prefetching on hover or touch events. This is useful for custom prefetch triggers outside of TanStack Router's built-in `` preloading: + +```tsx +import { withQueryPrefetch } from "../openapi/queries/router"; +import { prefetchUseFindPetById } from "../openapi/queries/prefetch"; +import { queryClient } from "../queryClient"; + +function PetLink({ petId }: { petId: number }) { + return ( + + prefetchUseFindPetById(queryClient, { path: { petId } }) + )} + > + View Pet + + ); +} +``` + +This spreads `onMouseEnter` and `onTouchStart` handlers that trigger the prefetch. + +## Router Configuration + +### External Cache Settings + +When using TanStack Query as an external cache, configure the router to delegate cache freshness to React Query: + +```tsx +import { createRouter } from "@tanstack/react-router"; +import { routeTree } from "./routeTree.gen"; + +const router = createRouter({ + routeTree, + defaultPreloadStaleTime: 0, // Let React Query handle cache freshness +}); +``` + +### Link Preloading + +TanStack Router's `` component supports intent-based preloading: + +```tsx + + View Pet + +``` + +Or set it globally: + +```tsx +const router = createRouter({ + routeTree, + defaultPreload: "intent", + defaultPreloadStaleTime: 0, +}); +``` + +When using `preload="intent"`, the router automatically calls the route's `loader` on hover/touch. If your loader uses `ensureUse*Data` (which the generated loaders do), prefetching happens automatically. + +## Important Notes + +### Router Params Are Strings + +TanStack Router params are always strings. You must parse them to the correct type before passing to the loader: + +```tsx +// Router params: { petId: string } +// API expects: { petId: number } +loader: ({ params }) => + loaderUseFindPetById({ queryClient })({ + params: { petId: Number(params.petId) }, // Convert string to number + }), +``` + +For type-safe parsing, consider using TanStack Router's `parseParams`: + +```tsx +export const Route = createFileRoute("/pets/$petId")({ + parseParams: (params) => ({ + petId: Number(params.petId), + }), + loader: ({ params }) => + loaderUseFindPetById({ queryClient })({ + params: { petId: params.petId }, // Already a number + }), +}); +``` diff --git a/docs/src/content/docs/guides/introduction.mdx b/docs/src/content/docs/guides/introduction.mdx index 283df3b..860d757 100644 --- a/docs/src/content/docs/guides/introduction.mdx +++ b/docs/src/content/docs/guides/introduction.mdx @@ -12,6 +12,7 @@ OpenAPI React Query Codegen is a code generator for creating React Query (also k - Generates custom functions that use React Query's `ensureQueryData` and `prefetchQuery` functions - Generates query keys and functions for query caching - Generates pure TypeScript clients generated by [@hey-api/openapi-ts](https://github.com/hey-api/openapi-ts) +- Generates loader factories and helpers for [TanStack Router](https://tanstack.com/router) integration ## Installation @@ -75,6 +76,7 @@ openapi/ │ ├── infiniteQueries.ts │ ├── prefetch.ts │ ├── queries.ts +│ ├── router.ts │ └── suspense.ts └── requests ├── index.ts @@ -177,8 +179,9 @@ export default App; - ensureQueryData.ts Generated ensureQueryData functions - queries.ts Generated query/mutation hooks - infiniteQueries.ts Generated infinite query hooks - - suspenses.ts Generated suspense hooks + - suspense.ts Generated suspense hooks - prefetch.ts Generated prefetch functions + - router.ts Generated loader factories and helpers for TanStack Router - requests Output code generated by `@hey-api/openapi-ts` diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 40ce37e..76329bc 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -24,7 +24,7 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; Generates custom react hooks that use React(TanStack) Query's useQuery, useSuspenseQuery, useMutation and useInfiniteQuery hooks. - Generates custom functions that use React Query's `ensureQueryData` and `prefetchQuery` functions to integrate into frameworks like Next.js and Remix. + Generates custom functions that use React Query's `ensureQueryData` and `prefetchQuery` functions, plus loader factories for TanStack Router, to integrate into frameworks like Next.js, Remix, and TanStack Start. Generates pure TypeScript clients generated by [@hey-api/openapi-ts](https://github.com/hey-api/openapi-ts) in case you still want to do type-safe API calls without React Query. diff --git a/src/constants.mts b/src/constants.mts index 651e6e8..8a14590 100644 --- a/src/constants.mts +++ b/src/constants.mts @@ -13,4 +13,5 @@ export const OpenApiRqFiles = { index: "index", prefetch: "prefetch", ensureQueryData: "ensureQueryData", + router: "router", } as const; diff --git a/src/tsmorph/buildRouter.mts b/src/tsmorph/buildRouter.mts new file mode 100644 index 0000000..60c621b --- /dev/null +++ b/src/tsmorph/buildRouter.mts @@ -0,0 +1,130 @@ +import { + StructureKind, + VariableDeclarationKind, + type VariableStatementStructure, +} from "ts-morph"; +import type { GenerationContext, OperationInfo } from "../types.mjs"; + +/** + * Build the withQueryPrefetch helper. + * This helper provides event handlers for hover/touch prefetching. + * + * Example output: + * export const withQueryPrefetch = (prefetch: () => Promise) => ({ + * onMouseEnter: () => void prefetch(), + * onTouchStart: () => void prefetch(), + * }); + */ +export function buildWithQueryPrefetch(): VariableStatementStructure { + const initializer = `(prefetch: () => Promise) => ({ + onMouseEnter: () => void prefetch(), + onTouchStart: () => void prefetch(), +})`; + + return { + kind: StructureKind.VariableStatement, + isExported: true, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + name: "withQueryPrefetch", + initializer, + }, + ], + }; +} + +/** + * Check if an operation has a path parameter that is not `never`. + */ +function hasPathParam(op: OperationInfo): boolean { + const pathParam = op.parameters.find((p) => p.name === "path"); + return Boolean(pathParam && pathParam.typeName !== "never"); +} + +/** + * Get required top-level keys (non-optional, non-never). + */ +function getRequiredTopLevelKeys(op: OperationInfo): string[] { + return op.parameters + .filter((p) => !p.optional && p.typeName !== "never") + .map((p) => p.name); +} + +/** + * Build loader factory for a GET operation. + * + * For operations without path parameter: + * export const loaderUseFindPets = (deps: { + * queryClient: QueryClient; + * clientOptions?: Options; + * }) => async () => { + * await ensureUseFindPetsData(deps.queryClient, deps.clientOptions); + * return null; + * }; + * + * For operations with path parameter: + * export const loaderUseFindPetById = (deps: { + * queryClient: QueryClient; + * clientOptions?: Omit, "path">; + * }) => async ({ params }: { params: FindPetByIdData["path"] }) => { + * const options: Options = { ...(deps.clientOptions ?? {}), path: params }; + * await ensureUseFindPetByIdData(deps.queryClient, options); + * return null; + * }; + */ +export function buildLoaderFactory( + op: OperationInfo, + ctx: GenerationContext, +): VariableStatementStructure { + const loaderName = `loaderUse${op.capitalizedMethodName}`; + const ensureFnName = `ensureUse${op.capitalizedMethodName}Data`; + + const dataTypeName = ctx.modelNames.includes( + `${op.capitalizedMethodName}Data`, + ) + ? `${op.capitalizedMethodName}Data` + : "unknown"; + + const hasPath = hasPathParam(op); + const requiredTopLevelKeys = getRequiredTopLevelKeys(op); + const requiredNonPathKeys = requiredTopLevelKeys.filter((k) => k !== "path"); + + // Determine if clientOptions should be optional + const clientOptionsOptional = hasPath + ? requiredNonPathKeys.length === 0 + : requiredTopLevelKeys.length === 0; + + const optionalMark = clientOptionsOptional ? "?" : ""; + + let initializer: string; + + if (hasPath) { + // Has path parameter - needs params in returned function + const clientOptionsType = `Omit, "path">`; + initializer = `(deps: { queryClient: QueryClient; clientOptions${optionalMark}: ${clientOptionsType} }) => async ({ params }: { params: ${dataTypeName}["path"] }) => { + const options: Options<${dataTypeName}, true> = { ...(deps.clientOptions ?? {}), path: params }; + await ${ensureFnName}(deps.queryClient, options); + return null; +}`; + } else { + // No path parameter - simpler factory + const clientOptionsType = `Options<${dataTypeName}, true>`; + initializer = `(deps: { queryClient: QueryClient; clientOptions${optionalMark}: ${clientOptionsType} }) => async () => { + await ${ensureFnName}(deps.queryClient, deps.clientOptions); + return null; +}`; + } + + return { + kind: StructureKind.VariableStatement, + isExported: true, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + name: loaderName, + initializer, + }, + ], + }; +} diff --git a/src/tsmorph/generateFiles.mts b/src/tsmorph/generateFiles.mts index b389aa2..aeb57b1 100644 --- a/src/tsmorph/generateFiles.mts +++ b/src/tsmorph/generateFiles.mts @@ -29,6 +29,7 @@ import { buildUseQueryHook, buildUseSuspenseQueryHook, } from "./buildQueryHooks.mjs"; +import { buildLoaderFactory, buildWithQueryPrefetch } from "./buildRouter.mjs"; import { buildAxiosErrorImport, buildClientImport, @@ -293,6 +294,88 @@ function generateEnsureQueryDataFile( return sourceFile.getFullText(); } +/** + * Build imports for router.ts file. + */ +function buildRouterFileImports( + operations: OperationInfo[], + ctx: GenerationContext, +): ImportDeclarationStructure[] { + const getOperations = operations.filter((op) => op.httpMethod === "GET"); + + // Get Data type names needed for GET operations + const dataTypeNames = getOperations + .map((op) => `${op.capitalizedMethodName}Data`) + .filter((name) => ctx.modelNames.includes(name)); + + // Get ensure function names + const ensureFnNames = getOperations.map( + (op) => `ensureUse${op.capitalizedMethodName}Data`, + ); + + const imports: ImportDeclarationStructure[] = [ + // Options import from client + buildClientImport(ctx), + // QueryClient import + { + kind: StructureKind.ImportDeclaration, + moduleSpecifier: "@tanstack/react-query", + namedImports: [{ name: "QueryClient", isTypeOnly: true }], + }, + ]; + + // Add Data types import if needed + if (dataTypeNames.length > 0) { + imports.push({ + kind: StructureKind.ImportDeclaration, + moduleSpecifier: "../requests/types.gen", + namedImports: dataTypeNames.map((name) => ({ name })), + }); + } + + // Add ensureQueryData imports + if (ensureFnNames.length > 0) { + imports.push({ + kind: StructureKind.ImportDeclaration, + moduleSpecifier: "./ensureQueryData", + namedImports: ensureFnNames.map((name) => ({ name })), + }); + } + + return imports; +} + +/** + * Generate the router.ts file content. + */ +function generateRouterFile( + operations: OperationInfo[], + ctx: GenerationContext, +): string { + const project = createGenerationProject(); + const sourceFile = project.createSourceFile( + `${OpenApiRqFiles.router}.ts`, + undefined, + { overwrite: true }, + ); + + // Add imports + sourceFile.addImportDeclarations(buildRouterFileImports(operations, ctx)); + + // Add withQueryPrefetch helper + sourceFile.addVariableStatement(buildWithQueryPrefetch()); + + // Only GET operations for loader factories + const getOperations = operations.filter((op) => op.httpMethod === "GET"); + + // Add loader factories + for (const op of getOperations) { + sourceFile.addVariableStatement(buildLoaderFactory(op, ctx)); + } + + return sourceFile.getFullText(); +} + /** * Add the generated header comment to file content. */ @@ -355,5 +438,12 @@ export function generateAllFiles( ctx.version, ), }, + { + name: `${OpenApiRqFiles.router}.ts`, + content: addHeaderComment( + generateRouterFile(operations, ctx), + ctx.version, + ), + }, ]; } diff --git a/src/tsmorph/index.mts b/src/tsmorph/index.mts index 59e3c0a..6422308 100644 --- a/src/tsmorph/index.mts +++ b/src/tsmorph/index.mts @@ -3,3 +3,4 @@ export { createGenerationProject } from "./projectFactory.mjs"; export * from "./buildCommon.mjs"; export * from "./buildQueryHooks.mjs"; export * from "./buildMutationHooks.mjs"; +export * from "./buildRouter.mjs"; diff --git a/tests/__snapshots__/createSource.test.ts.snap b/tests/__snapshots__/createSource.test.ts.snap index d759777..bded83c 100644 --- a/tests/__snapshots__/createSource.test.ts.snap +++ b/tests/__snapshots__/createSource.test.ts.snap @@ -112,6 +112,38 @@ export const prefetchUseFindPaginatedPets = (queryClient: QueryClient, clientOpt " `; +exports[`createSource > createSource - @hey-api/client-axios 6`] = ` +"// generated with @7nohe/openapi-react-query-codegen@1.0.0 + +import { type Options } from "@hey-api/client-axios"; +import { type QueryClient } from "@tanstack/react-query"; +import { FindPetsData, FindPetByIdData, FindPaginatedPetsData } from "../requests/types.gen"; +import { ensureUseFindPetsData, ensureUseGetNotDefinedData, ensureUseFindPetByIdData, ensureUseFindPaginatedPetsData } from "./ensureQueryData"; + +export const withQueryPrefetch = (prefetch: () => Promise) => ({ + onMouseEnter: () => void prefetch(), + onTouchStart: () => void prefetch(), + }); +export const loaderUseFindPets = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { + await ensureUseFindPetsData(deps.queryClient, deps.clientOptions); + return null; + }; +export const loaderUseGetNotDefined = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { + await ensureUseGetNotDefinedData(deps.queryClient, deps.clientOptions); + return null; + }; +export const loaderUseFindPetById = (deps: { queryClient: QueryClient; clientOptions?: Omit, "path"> }) => async ({ params }: { params: FindPetByIdData["path"] }) => { + const options: Options = { ...(deps.clientOptions ?? {}), path: params }; + await ensureUseFindPetByIdData(deps.queryClient, options); + return null; + }; +export const loaderUseFindPaginatedPets = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { + await ensureUseFindPaginatedPetsData(deps.queryClient, deps.clientOptions); + return null; + }; +" +`; + exports[`createSource > createSource - @hey-api/client-fetch 1`] = ` "// generated with @7nohe/openapi-react-query-codegen@1.0.0 @@ -219,3 +251,35 @@ export const prefetchUseFindPetById = (queryClient: QueryClient, clientOptions: export const prefetchUseFindPaginatedPets = (queryClient: QueryClient, clientOptions: Options = {}) => queryClient.prefetchQuery({ queryKey: Common.UseFindPaginatedPetsKeyFn(clientOptions), queryFn: () => findPaginatedPets({ ...clientOptions }).then(response => response.data) }); " `; + +exports[`createSource > createSource - @hey-api/client-fetch 6`] = ` +"// generated with @7nohe/openapi-react-query-codegen@1.0.0 + +import { type Options } from "@hey-api/client-fetch"; +import { type QueryClient } from "@tanstack/react-query"; +import { FindPetsData, FindPetByIdData, FindPaginatedPetsData } from "../requests/types.gen"; +import { ensureUseFindPetsData, ensureUseGetNotDefinedData, ensureUseFindPetByIdData, ensureUseFindPaginatedPetsData } from "./ensureQueryData"; + +export const withQueryPrefetch = (prefetch: () => Promise) => ({ + onMouseEnter: () => void prefetch(), + onTouchStart: () => void prefetch(), + }); +export const loaderUseFindPets = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { + await ensureUseFindPetsData(deps.queryClient, deps.clientOptions); + return null; + }; +export const loaderUseGetNotDefined = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { + await ensureUseGetNotDefinedData(deps.queryClient, deps.clientOptions); + return null; + }; +export const loaderUseFindPetById = (deps: { queryClient: QueryClient; clientOptions?: Omit, "path"> }) => async ({ params }: { params: FindPetByIdData["path"] }) => { + const options: Options = { ...(deps.clientOptions ?? {}), path: params }; + await ensureUseFindPetByIdData(deps.queryClient, options); + return null; + }; +export const loaderUseFindPaginatedPets = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { + await ensureUseFindPaginatedPetsData(deps.queryClient, deps.clientOptions); + return null; + }; +" +`; diff --git a/tests/createSource.test.ts b/tests/createSource.test.ts index 8b6eba4..0ceea77 100644 --- a/tests/createSource.test.ts +++ b/tests/createSource.test.ts @@ -18,7 +18,7 @@ describe(fileName, () => { client: "@hey-api/client-fetch", }); - expect(source).toHaveLength(7); + expect(source).toHaveLength(8); expect(source.map((s) => s.name)).toEqual([ "index.ts", "common.ts", @@ -27,6 +27,7 @@ describe(fileName, () => { "infiniteQueries.ts", "prefetch.ts", "ensureQueryData.ts", + "router.ts", ]); const indexTs = source.find((s) => s.name === "index.ts"); @@ -43,6 +44,9 @@ describe(fileName, () => { const prefetchTs = source.find((s) => s.name === "prefetch.ts"); expect(prefetchTs?.content).toMatchSnapshot(); + + const routerTs = source.find((s) => s.name === "router.ts"); + expect(routerTs?.content).toMatchSnapshot(); }); test("createSource - @hey-api/client-axios", async () => { @@ -69,5 +73,8 @@ describe(fileName, () => { const prefetchTs = source.find((s) => s.name === "prefetch.ts"); expect(prefetchTs?.content).toMatchSnapshot(); + + const routerTs = source.find((s) => s.name === "router.ts"); + expect(routerTs?.content).toMatchSnapshot(); }); }); diff --git a/tests/tsmorph/buildRouter.test.ts b/tests/tsmorph/buildRouter.test.ts new file mode 100644 index 0000000..ea3a081 --- /dev/null +++ b/tests/tsmorph/buildRouter.test.ts @@ -0,0 +1,180 @@ +import { StructureKind, VariableDeclarationKind } from "ts-morph"; +import { describe, expect, it } from "vitest"; +import { + buildLoaderFactory, + buildWithQueryPrefetch, +} from "../../src/tsmorph/buildRouter.mjs"; +import type { GenerationContext, OperationInfo } from "../../src/types.mjs"; + +// Operation without path parameter (all optional) +const mockOperationNoPath: OperationInfo = { + methodName: "findPets", + capitalizedMethodName: "FindPets", + httpMethod: "GET", + isDeprecated: false, + parameters: [{ name: "limit", typeName: "number", optional: true }], + allParamsOptional: true, + isPaginatable: false, +}; + +// Operation with path parameter +const mockOperationWithPath: OperationInfo = { + methodName: "findPetById", + capitalizedMethodName: "FindPetById", + httpMethod: "GET", + isDeprecated: false, + parameters: [ + { name: "path", typeName: "{ petId: number }", optional: false }, + ], + allParamsOptional: false, + isPaginatable: false, +}; + +// Operation with path and other required params +const mockOperationWithPathAndRequired: OperationInfo = { + methodName: "getPetDetails", + capitalizedMethodName: "GetPetDetails", + httpMethod: "GET", + isDeprecated: false, + parameters: [ + { name: "path", typeName: "{ petId: number }", optional: false }, + { name: "query", typeName: "{ include: string }", optional: false }, + ], + allParamsOptional: false, + isPaginatable: false, +}; + +// Operation with path: never (no actual path params) +const mockOperationWithPathNever: OperationInfo = { + methodName: "listPets", + capitalizedMethodName: "ListPets", + httpMethod: "GET", + isDeprecated: false, + parameters: [ + { name: "path", typeName: "never", optional: true }, + { name: "query", typeName: "{ limit: number }", optional: true }, + ], + allParamsOptional: true, + isPaginatable: false, +}; + +const mockFetchContext: GenerationContext = { + client: "@hey-api/client-fetch", + modelNames: [ + "Pet", + "FindPetsData", + "FindPetByIdData", + "GetPetDetailsData", + "ListPetsData", + ], + serviceNames: ["findPets", "findPetById", "getPetDetails", "listPets"], + pageParam: "page", + nextPageParam: "nextPage", + initialPageParam: "1", + version: "1.0.0", +}; + +describe("buildRouter", () => { + describe("buildWithQueryPrefetch", () => { + it("should build withQueryPrefetch helper", () => { + const result = buildWithQueryPrefetch(); + + expect(result.kind).toBe(StructureKind.VariableStatement); + expect(result.isExported).toBe(true); + expect(result.declarationKind).toBe(VariableDeclarationKind.Const); + expect(result.declarations[0].name).toBe("withQueryPrefetch"); + + const initializer = result.declarations[0].initializer as string; + expect(initializer).toContain("prefetch: () => Promise"); + expect(initializer).toContain("onMouseEnter: () => void prefetch()"); + expect(initializer).toContain("onTouchStart: () => void prefetch()"); + }); + }); + + describe("buildLoaderFactory", () => { + it("should build loader factory for operation without path param", () => { + const result = buildLoaderFactory(mockOperationNoPath, mockFetchContext); + + expect(result.kind).toBe(StructureKind.VariableStatement); + expect(result.isExported).toBe(true); + expect(result.declarationKind).toBe(VariableDeclarationKind.Const); + expect(result.declarations[0].name).toBe("loaderUseFindPets"); + + const initializer = result.declarations[0].initializer as string; + expect(initializer).toContain("queryClient: QueryClient"); + expect(initializer).toContain("clientOptions?:"); + expect(initializer).toContain("Options"); + expect(initializer).toContain("async () =>"); + expect(initializer).toContain( + "ensureUseFindPetsData(deps.queryClient, deps.clientOptions)", + ); + expect(initializer).toContain("return null"); + }); + + it("should build loader factory for operation with path param", () => { + const result = buildLoaderFactory( + mockOperationWithPath, + mockFetchContext, + ); + + expect(result.declarations[0].name).toBe("loaderUseFindPetById"); + + const initializer = result.declarations[0].initializer as string; + expect(initializer).toContain("queryClient: QueryClient"); + expect(initializer).toContain("clientOptions?:"); + expect(initializer).toContain( + 'Omit, "path">', + ); + expect(initializer).toContain("async ({ params }:"); + expect(initializer).toContain('params: FindPetByIdData["path"]'); + expect(initializer).toContain("...(deps.clientOptions ?? {})"); + expect(initializer).toContain("path: params"); + expect(initializer).toContain( + "ensureUseFindPetByIdData(deps.queryClient, options)", + ); + }); + + it("should make clientOptions required when non-path required params exist", () => { + const result = buildLoaderFactory( + mockOperationWithPathAndRequired, + mockFetchContext, + ); + + const initializer = result.declarations[0].initializer as string; + // clientOptions should NOT have ? (required) + expect(initializer).toContain("clientOptions:"); + expect(initializer).not.toMatch(/clientOptions\?:/); + }); + + it("should treat path: never as no path param", () => { + const result = buildLoaderFactory( + mockOperationWithPathNever, + mockFetchContext, + ); + + expect(result.declarations[0].name).toBe("loaderUseListPets"); + + const initializer = result.declarations[0].initializer as string; + // Should NOT have params in the inner function + expect(initializer).toContain("async () =>"); + expect(initializer).not.toContain("{ params }"); + // clientOptions should be optional since all params are optional + expect(initializer).toContain("clientOptions?:"); + }); + + it("should use unknown for missing Data type", () => { + const contextWithoutDataType: GenerationContext = { + ...mockFetchContext, + modelNames: [], + }; + + const result = buildLoaderFactory( + mockOperationNoPath, + contextWithoutDataType, + ); + + const initializer = result.declarations[0].initializer as string; + expect(initializer).toContain("Options"); + }); + }); +}); From 8f7bfdd2ec95925a86f4a92d2d4036e5afed055c Mon Sep 17 00:00:00 2001 From: Urata Daiki <7nohe@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:05:13 +0900 Subject: [PATCH 2/2] docs: add TanStack Router integration guide using ensureQueryData --- README.md | 70 ------- .../content/docs/examples/tanstack-router.md | 76 +++----- docs/src/content/docs/guides/introduction.mdx | 3 - docs/src/content/docs/index.mdx | 2 +- src/constants.mts | 1 - src/tsmorph/buildRouter.mts | 130 ------------- src/tsmorph/generateFiles.mts | 90 --------- src/tsmorph/index.mts | 1 - tests/__snapshots__/createSource.test.ts.snap | 64 ------- tests/createSource.test.ts | 9 +- tests/tsmorph/buildRouter.test.ts | 180 ------------------ 11 files changed, 27 insertions(+), 599 deletions(-) delete mode 100644 src/tsmorph/buildRouter.mts delete mode 100644 tests/tsmorph/buildRouter.test.ts diff --git a/README.md b/README.md index bbd3558..652c5ea 100644 --- a/README.md +++ b/README.md @@ -10,73 +10,3 @@ - Generates custom functions that use React Query's `ensureQueryData` and `prefetchQuery` functions - Generates query keys and functions for query caching - Generates pure TypeScript clients generated by [@hey-api/openapi-ts](https://github.com/hey-api/openapi-ts) -- Generates loader factories and helpers for [TanStack Router](https://tanstack.com/router) integration - -## TanStack Router Integration - -The generated `router.ts` file provides loader factories and helpers for seamless integration with TanStack Router. - -### Using Loader Factories - -Use `loaderUse*` functions in your route definitions to prefetch data: - -```tsx -// routes/pets.$petId.tsx -import { createFileRoute } from "@tanstack/react-router"; -import { loaderUseFindPetById } from "../openapi/queries/router"; -import { queryClient } from "../queryClient"; - -export const Route = createFileRoute("/pets/$petId")({ - loader: ({ params }) => - loaderUseFindPetById({ queryClient })({ - params: { petId: Number(params.petId) }, - }), - component: PetDetail, -}); -``` - -For SSR/TanStack Start, pass `queryClient` from the router context: - -```tsx -loader: ({ context, params }) => - loaderUseFindPetById({ queryClient: context.queryClient })({ - params: { petId: Number(params.petId) }, - }), -``` - -### Using withQueryPrefetch for Hover/Touch Prefetching - -The `withQueryPrefetch` helper enables prefetching on hover or touch: - -```tsx -import { withQueryPrefetch } from "../openapi/queries/router"; -import { prefetchUseFindPetById } from "../openapi/queries/prefetch"; -import { queryClient } from "../queryClient"; - -function PetLink({ petId }: { petId: number }) { - return ( - - prefetchUseFindPetById(queryClient, { path: { petId } }) - )} - > - View Pet - - ); -} -``` - -### Important Notes - -- **Router params are strings**: TanStack Router params are always strings. You must parse them to the correct type (e.g., `Number(params.petId)`) before passing to the loader. -- **External cache configuration**: When using TanStack Query as the cache, set `defaultPreloadStaleTime: 0` in your router configuration to let React Query handle cache freshness: - -```tsx -const router = createRouter({ - routeTree, - defaultPreloadStaleTime: 0, -}); -``` - -- **Link preloading**: When using `` or `defaultPreload: "intent"`, TanStack Router will automatically call the route's `loader` on hover/touch. If your loader uses `ensureUse*Data`, prefetching happens automatically without needing `withQueryPrefetch`. diff --git a/docs/src/content/docs/examples/tanstack-router.md b/docs/src/content/docs/examples/tanstack-router.md index 17f0577..7b09dbb 100644 --- a/docs/src/content/docs/examples/tanstack-router.md +++ b/docs/src/content/docs/examples/tanstack-router.md @@ -5,27 +5,21 @@ description: Using TanStack Router with OpenAPI React Query Codegen for data loa Example of using TanStack Router can be found in the [`examples/tanstack-router-app`](https://github.com/7nohe/openapi-react-query-codegen/tree/main/examples/tanstack-router-app) directory of the repository. -## Generated Files +## Route Data Loading -The codegen generates a `router.ts` file that provides: - -- **Loader factories** (`loaderUse*`) for route data loading -- **`withQueryPrefetch`** helper for hover/touch prefetching - -## Using Loader Factories - -Use `loaderUse*` functions in your route definitions to prefetch data before the route renders: +Use the generated `ensureQueryData` functions in your route loaders to prefetch data before the route renders: ```tsx // routes/pets.$petId.tsx import { createFileRoute } from "@tanstack/react-router"; -import { loaderUseFindPetById } from "../openapi/queries/router"; +import { ensureUseFindPetByIdData } from "../openapi/queries/ensureQueryData"; +import { useFindPetById } from "../openapi/queries"; import { queryClient } from "../queryClient"; export const Route = createFileRoute("/pets/$petId")({ loader: ({ params }) => - loaderUseFindPetById({ queryClient })({ - params: { petId: Number(params.petId) }, + ensureUseFindPetByIdData(queryClient, { + path: { petId: Number(params.petId) }, }), component: PetDetail, }); @@ -40,13 +34,13 @@ function PetDetail() { ### For SSR / TanStack Start -When using SSR or TanStack Start, pass `queryClient` from the router context instead of importing it directly: +When using SSR or TanStack Start, pass `queryClient` from the router context: ```tsx export const Route = createFileRoute("/pets/$petId")({ loader: ({ context, params }) => - loaderUseFindPetById({ queryClient: context.queryClient })({ - params: { petId: Number(params.petId) }, + ensureUseFindPetByIdData(context.queryClient, { + path: { petId: Number(params.petId) }, }), component: PetDetail, }); @@ -54,49 +48,33 @@ export const Route = createFileRoute("/pets/$petId")({ ### Operations Without Path Parameters -For operations without path parameters, the loader is simpler: - ```tsx -import { loaderUseFindPets } from "../openapi/queries/router"; +import { ensureUseFindPetsData } from "../openapi/queries/ensureQueryData"; export const Route = createFileRoute("/pets")({ - loader: () => loaderUseFindPets({ queryClient })(), + loader: () => ensureUseFindPetsData(queryClient), component: PetList, }); ``` -### Passing Additional Options +## Prefetching on Hover/Touch -You can pass additional client options through the `clientOptions` parameter: +Use `prefetchQuery` functions for custom prefetch triggers: ```tsx -loader: ({ params }) => - loaderUseFindPetById({ - queryClient, - clientOptions: { - headers: { "X-Custom-Header": "value" }, - }, - })({ - params: { petId: Number(params.petId) }, - }), -``` - -## Using withQueryPrefetch - -The `withQueryPrefetch` helper enables prefetching on hover or touch events. This is useful for custom prefetch triggers outside of TanStack Router's built-in `` preloading: - -```tsx -import { withQueryPrefetch } from "../openapi/queries/router"; import { prefetchUseFindPetById } from "../openapi/queries/prefetch"; import { queryClient } from "../queryClient"; function PetLink({ petId }: { petId: number }) { + const handlePrefetch = () => { + prefetchUseFindPetById(queryClient, { path: { petId } }); + }; + return ( - prefetchUseFindPetById(queryClient, { path: { petId } }) - )} + onMouseEnter={handlePrefetch} + onTouchStart={handlePrefetch} > View Pet @@ -104,8 +82,6 @@ function PetLink({ petId }: { petId: number }) { } ``` -This spreads `onMouseEnter` and `onTouchStart` handlers that trigger the prefetch. - ## Router Configuration ### External Cache Settings @@ -142,20 +118,18 @@ const router = createRouter({ }); ``` -When using `preload="intent"`, the router automatically calls the route's `loader` on hover/touch. If your loader uses `ensureUse*Data` (which the generated loaders do), prefetching happens automatically. +When using `preload="intent"`, the router automatically calls the route's `loader` on hover/touch. ## Important Notes ### Router Params Are Strings -TanStack Router params are always strings. You must parse them to the correct type before passing to the loader: +TanStack Router params are always strings. You must parse them to the correct type: ```tsx -// Router params: { petId: string } -// API expects: { petId: number } loader: ({ params }) => - loaderUseFindPetById({ queryClient })({ - params: { petId: Number(params.petId) }, // Convert string to number + ensureUseFindPetByIdData(queryClient, { + path: { petId: Number(params.petId) }, // Convert string to number }), ``` @@ -167,8 +141,8 @@ export const Route = createFileRoute("/pets/$petId")({ petId: Number(params.petId), }), loader: ({ params }) => - loaderUseFindPetById({ queryClient })({ - params: { petId: params.petId }, // Already a number + ensureUseFindPetByIdData(queryClient, { + path: { petId: params.petId }, // Already a number }), }); ``` diff --git a/docs/src/content/docs/guides/introduction.mdx b/docs/src/content/docs/guides/introduction.mdx index 860d757..636fc49 100644 --- a/docs/src/content/docs/guides/introduction.mdx +++ b/docs/src/content/docs/guides/introduction.mdx @@ -12,7 +12,6 @@ OpenAPI React Query Codegen is a code generator for creating React Query (also k - Generates custom functions that use React Query's `ensureQueryData` and `prefetchQuery` functions - Generates query keys and functions for query caching - Generates pure TypeScript clients generated by [@hey-api/openapi-ts](https://github.com/hey-api/openapi-ts) -- Generates loader factories and helpers for [TanStack Router](https://tanstack.com/router) integration ## Installation @@ -76,7 +75,6 @@ openapi/ │ ├── infiniteQueries.ts │ ├── prefetch.ts │ ├── queries.ts -│ ├── router.ts │ └── suspense.ts └── requests ├── index.ts @@ -181,7 +179,6 @@ export default App; - infiniteQueries.ts Generated infinite query hooks - suspense.ts Generated suspense hooks - prefetch.ts Generated prefetch functions - - router.ts Generated loader factories and helpers for TanStack Router - requests Output code generated by `@hey-api/openapi-ts` diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 76329bc..b8593af 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -24,7 +24,7 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; Generates custom react hooks that use React(TanStack) Query's useQuery, useSuspenseQuery, useMutation and useInfiniteQuery hooks. - Generates custom functions that use React Query's `ensureQueryData` and `prefetchQuery` functions, plus loader factories for TanStack Router, to integrate into frameworks like Next.js, Remix, and TanStack Start. + Generates custom functions that use React Query's `ensureQueryData` and `prefetchQuery` functions to integrate into frameworks like Next.js, Remix, and TanStack Router. Generates pure TypeScript clients generated by [@hey-api/openapi-ts](https://github.com/hey-api/openapi-ts) in case you still want to do type-safe API calls without React Query. diff --git a/src/constants.mts b/src/constants.mts index 8a14590..651e6e8 100644 --- a/src/constants.mts +++ b/src/constants.mts @@ -13,5 +13,4 @@ export const OpenApiRqFiles = { index: "index", prefetch: "prefetch", ensureQueryData: "ensureQueryData", - router: "router", } as const; diff --git a/src/tsmorph/buildRouter.mts b/src/tsmorph/buildRouter.mts deleted file mode 100644 index 60c621b..0000000 --- a/src/tsmorph/buildRouter.mts +++ /dev/null @@ -1,130 +0,0 @@ -import { - StructureKind, - VariableDeclarationKind, - type VariableStatementStructure, -} from "ts-morph"; -import type { GenerationContext, OperationInfo } from "../types.mjs"; - -/** - * Build the withQueryPrefetch helper. - * This helper provides event handlers for hover/touch prefetching. - * - * Example output: - * export const withQueryPrefetch = (prefetch: () => Promise) => ({ - * onMouseEnter: () => void prefetch(), - * onTouchStart: () => void prefetch(), - * }); - */ -export function buildWithQueryPrefetch(): VariableStatementStructure { - const initializer = `(prefetch: () => Promise) => ({ - onMouseEnter: () => void prefetch(), - onTouchStart: () => void prefetch(), -})`; - - return { - kind: StructureKind.VariableStatement, - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: "withQueryPrefetch", - initializer, - }, - ], - }; -} - -/** - * Check if an operation has a path parameter that is not `never`. - */ -function hasPathParam(op: OperationInfo): boolean { - const pathParam = op.parameters.find((p) => p.name === "path"); - return Boolean(pathParam && pathParam.typeName !== "never"); -} - -/** - * Get required top-level keys (non-optional, non-never). - */ -function getRequiredTopLevelKeys(op: OperationInfo): string[] { - return op.parameters - .filter((p) => !p.optional && p.typeName !== "never") - .map((p) => p.name); -} - -/** - * Build loader factory for a GET operation. - * - * For operations without path parameter: - * export const loaderUseFindPets = (deps: { - * queryClient: QueryClient; - * clientOptions?: Options; - * }) => async () => { - * await ensureUseFindPetsData(deps.queryClient, deps.clientOptions); - * return null; - * }; - * - * For operations with path parameter: - * export const loaderUseFindPetById = (deps: { - * queryClient: QueryClient; - * clientOptions?: Omit, "path">; - * }) => async ({ params }: { params: FindPetByIdData["path"] }) => { - * const options: Options = { ...(deps.clientOptions ?? {}), path: params }; - * await ensureUseFindPetByIdData(deps.queryClient, options); - * return null; - * }; - */ -export function buildLoaderFactory( - op: OperationInfo, - ctx: GenerationContext, -): VariableStatementStructure { - const loaderName = `loaderUse${op.capitalizedMethodName}`; - const ensureFnName = `ensureUse${op.capitalizedMethodName}Data`; - - const dataTypeName = ctx.modelNames.includes( - `${op.capitalizedMethodName}Data`, - ) - ? `${op.capitalizedMethodName}Data` - : "unknown"; - - const hasPath = hasPathParam(op); - const requiredTopLevelKeys = getRequiredTopLevelKeys(op); - const requiredNonPathKeys = requiredTopLevelKeys.filter((k) => k !== "path"); - - // Determine if clientOptions should be optional - const clientOptionsOptional = hasPath - ? requiredNonPathKeys.length === 0 - : requiredTopLevelKeys.length === 0; - - const optionalMark = clientOptionsOptional ? "?" : ""; - - let initializer: string; - - if (hasPath) { - // Has path parameter - needs params in returned function - const clientOptionsType = `Omit, "path">`; - initializer = `(deps: { queryClient: QueryClient; clientOptions${optionalMark}: ${clientOptionsType} }) => async ({ params }: { params: ${dataTypeName}["path"] }) => { - const options: Options<${dataTypeName}, true> = { ...(deps.clientOptions ?? {}), path: params }; - await ${ensureFnName}(deps.queryClient, options); - return null; -}`; - } else { - // No path parameter - simpler factory - const clientOptionsType = `Options<${dataTypeName}, true>`; - initializer = `(deps: { queryClient: QueryClient; clientOptions${optionalMark}: ${clientOptionsType} }) => async () => { - await ${ensureFnName}(deps.queryClient, deps.clientOptions); - return null; -}`; - } - - return { - kind: StructureKind.VariableStatement, - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: loaderName, - initializer, - }, - ], - }; -} diff --git a/src/tsmorph/generateFiles.mts b/src/tsmorph/generateFiles.mts index aeb57b1..b389aa2 100644 --- a/src/tsmorph/generateFiles.mts +++ b/src/tsmorph/generateFiles.mts @@ -29,7 +29,6 @@ import { buildUseQueryHook, buildUseSuspenseQueryHook, } from "./buildQueryHooks.mjs"; -import { buildLoaderFactory, buildWithQueryPrefetch } from "./buildRouter.mjs"; import { buildAxiosErrorImport, buildClientImport, @@ -294,88 +293,6 @@ function generateEnsureQueryDataFile( return sourceFile.getFullText(); } -/** - * Build imports for router.ts file. - */ -function buildRouterFileImports( - operations: OperationInfo[], - ctx: GenerationContext, -): ImportDeclarationStructure[] { - const getOperations = operations.filter((op) => op.httpMethod === "GET"); - - // Get Data type names needed for GET operations - const dataTypeNames = getOperations - .map((op) => `${op.capitalizedMethodName}Data`) - .filter((name) => ctx.modelNames.includes(name)); - - // Get ensure function names - const ensureFnNames = getOperations.map( - (op) => `ensureUse${op.capitalizedMethodName}Data`, - ); - - const imports: ImportDeclarationStructure[] = [ - // Options import from client - buildClientImport(ctx), - // QueryClient import - { - kind: StructureKind.ImportDeclaration, - moduleSpecifier: "@tanstack/react-query", - namedImports: [{ name: "QueryClient", isTypeOnly: true }], - }, - ]; - - // Add Data types import if needed - if (dataTypeNames.length > 0) { - imports.push({ - kind: StructureKind.ImportDeclaration, - moduleSpecifier: "../requests/types.gen", - namedImports: dataTypeNames.map((name) => ({ name })), - }); - } - - // Add ensureQueryData imports - if (ensureFnNames.length > 0) { - imports.push({ - kind: StructureKind.ImportDeclaration, - moduleSpecifier: "./ensureQueryData", - namedImports: ensureFnNames.map((name) => ({ name })), - }); - } - - return imports; -} - -/** - * Generate the router.ts file content. - */ -function generateRouterFile( - operations: OperationInfo[], - ctx: GenerationContext, -): string { - const project = createGenerationProject(); - const sourceFile = project.createSourceFile( - `${OpenApiRqFiles.router}.ts`, - undefined, - { overwrite: true }, - ); - - // Add imports - sourceFile.addImportDeclarations(buildRouterFileImports(operations, ctx)); - - // Add withQueryPrefetch helper - sourceFile.addVariableStatement(buildWithQueryPrefetch()); - - // Only GET operations for loader factories - const getOperations = operations.filter((op) => op.httpMethod === "GET"); - - // Add loader factories - for (const op of getOperations) { - sourceFile.addVariableStatement(buildLoaderFactory(op, ctx)); - } - - return sourceFile.getFullText(); -} - /** * Add the generated header comment to file content. */ @@ -438,12 +355,5 @@ export function generateAllFiles( ctx.version, ), }, - { - name: `${OpenApiRqFiles.router}.ts`, - content: addHeaderComment( - generateRouterFile(operations, ctx), - ctx.version, - ), - }, ]; } diff --git a/src/tsmorph/index.mts b/src/tsmorph/index.mts index 6422308..59e3c0a 100644 --- a/src/tsmorph/index.mts +++ b/src/tsmorph/index.mts @@ -3,4 +3,3 @@ export { createGenerationProject } from "./projectFactory.mjs"; export * from "./buildCommon.mjs"; export * from "./buildQueryHooks.mjs"; export * from "./buildMutationHooks.mjs"; -export * from "./buildRouter.mjs"; diff --git a/tests/__snapshots__/createSource.test.ts.snap b/tests/__snapshots__/createSource.test.ts.snap index bded83c..d759777 100644 --- a/tests/__snapshots__/createSource.test.ts.snap +++ b/tests/__snapshots__/createSource.test.ts.snap @@ -112,38 +112,6 @@ export const prefetchUseFindPaginatedPets = (queryClient: QueryClient, clientOpt " `; -exports[`createSource > createSource - @hey-api/client-axios 6`] = ` -"// generated with @7nohe/openapi-react-query-codegen@1.0.0 - -import { type Options } from "@hey-api/client-axios"; -import { type QueryClient } from "@tanstack/react-query"; -import { FindPetsData, FindPetByIdData, FindPaginatedPetsData } from "../requests/types.gen"; -import { ensureUseFindPetsData, ensureUseGetNotDefinedData, ensureUseFindPetByIdData, ensureUseFindPaginatedPetsData } from "./ensureQueryData"; - -export const withQueryPrefetch = (prefetch: () => Promise) => ({ - onMouseEnter: () => void prefetch(), - onTouchStart: () => void prefetch(), - }); -export const loaderUseFindPets = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { - await ensureUseFindPetsData(deps.queryClient, deps.clientOptions); - return null; - }; -export const loaderUseGetNotDefined = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { - await ensureUseGetNotDefinedData(deps.queryClient, deps.clientOptions); - return null; - }; -export const loaderUseFindPetById = (deps: { queryClient: QueryClient; clientOptions?: Omit, "path"> }) => async ({ params }: { params: FindPetByIdData["path"] }) => { - const options: Options = { ...(deps.clientOptions ?? {}), path: params }; - await ensureUseFindPetByIdData(deps.queryClient, options); - return null; - }; -export const loaderUseFindPaginatedPets = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { - await ensureUseFindPaginatedPetsData(deps.queryClient, deps.clientOptions); - return null; - }; -" -`; - exports[`createSource > createSource - @hey-api/client-fetch 1`] = ` "// generated with @7nohe/openapi-react-query-codegen@1.0.0 @@ -251,35 +219,3 @@ export const prefetchUseFindPetById = (queryClient: QueryClient, clientOptions: export const prefetchUseFindPaginatedPets = (queryClient: QueryClient, clientOptions: Options = {}) => queryClient.prefetchQuery({ queryKey: Common.UseFindPaginatedPetsKeyFn(clientOptions), queryFn: () => findPaginatedPets({ ...clientOptions }).then(response => response.data) }); " `; - -exports[`createSource > createSource - @hey-api/client-fetch 6`] = ` -"// generated with @7nohe/openapi-react-query-codegen@1.0.0 - -import { type Options } from "@hey-api/client-fetch"; -import { type QueryClient } from "@tanstack/react-query"; -import { FindPetsData, FindPetByIdData, FindPaginatedPetsData } from "../requests/types.gen"; -import { ensureUseFindPetsData, ensureUseGetNotDefinedData, ensureUseFindPetByIdData, ensureUseFindPaginatedPetsData } from "./ensureQueryData"; - -export const withQueryPrefetch = (prefetch: () => Promise) => ({ - onMouseEnter: () => void prefetch(), - onTouchStart: () => void prefetch(), - }); -export const loaderUseFindPets = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { - await ensureUseFindPetsData(deps.queryClient, deps.clientOptions); - return null; - }; -export const loaderUseGetNotDefined = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { - await ensureUseGetNotDefinedData(deps.queryClient, deps.clientOptions); - return null; - }; -export const loaderUseFindPetById = (deps: { queryClient: QueryClient; clientOptions?: Omit, "path"> }) => async ({ params }: { params: FindPetByIdData["path"] }) => { - const options: Options = { ...(deps.clientOptions ?? {}), path: params }; - await ensureUseFindPetByIdData(deps.queryClient, options); - return null; - }; -export const loaderUseFindPaginatedPets = (deps: { queryClient: QueryClient; clientOptions?: Options }) => async () => { - await ensureUseFindPaginatedPetsData(deps.queryClient, deps.clientOptions); - return null; - }; -" -`; diff --git a/tests/createSource.test.ts b/tests/createSource.test.ts index 0ceea77..8b6eba4 100644 --- a/tests/createSource.test.ts +++ b/tests/createSource.test.ts @@ -18,7 +18,7 @@ describe(fileName, () => { client: "@hey-api/client-fetch", }); - expect(source).toHaveLength(8); + expect(source).toHaveLength(7); expect(source.map((s) => s.name)).toEqual([ "index.ts", "common.ts", @@ -27,7 +27,6 @@ describe(fileName, () => { "infiniteQueries.ts", "prefetch.ts", "ensureQueryData.ts", - "router.ts", ]); const indexTs = source.find((s) => s.name === "index.ts"); @@ -44,9 +43,6 @@ describe(fileName, () => { const prefetchTs = source.find((s) => s.name === "prefetch.ts"); expect(prefetchTs?.content).toMatchSnapshot(); - - const routerTs = source.find((s) => s.name === "router.ts"); - expect(routerTs?.content).toMatchSnapshot(); }); test("createSource - @hey-api/client-axios", async () => { @@ -73,8 +69,5 @@ describe(fileName, () => { const prefetchTs = source.find((s) => s.name === "prefetch.ts"); expect(prefetchTs?.content).toMatchSnapshot(); - - const routerTs = source.find((s) => s.name === "router.ts"); - expect(routerTs?.content).toMatchSnapshot(); }); }); diff --git a/tests/tsmorph/buildRouter.test.ts b/tests/tsmorph/buildRouter.test.ts deleted file mode 100644 index ea3a081..0000000 --- a/tests/tsmorph/buildRouter.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { StructureKind, VariableDeclarationKind } from "ts-morph"; -import { describe, expect, it } from "vitest"; -import { - buildLoaderFactory, - buildWithQueryPrefetch, -} from "../../src/tsmorph/buildRouter.mjs"; -import type { GenerationContext, OperationInfo } from "../../src/types.mjs"; - -// Operation without path parameter (all optional) -const mockOperationNoPath: OperationInfo = { - methodName: "findPets", - capitalizedMethodName: "FindPets", - httpMethod: "GET", - isDeprecated: false, - parameters: [{ name: "limit", typeName: "number", optional: true }], - allParamsOptional: true, - isPaginatable: false, -}; - -// Operation with path parameter -const mockOperationWithPath: OperationInfo = { - methodName: "findPetById", - capitalizedMethodName: "FindPetById", - httpMethod: "GET", - isDeprecated: false, - parameters: [ - { name: "path", typeName: "{ petId: number }", optional: false }, - ], - allParamsOptional: false, - isPaginatable: false, -}; - -// Operation with path and other required params -const mockOperationWithPathAndRequired: OperationInfo = { - methodName: "getPetDetails", - capitalizedMethodName: "GetPetDetails", - httpMethod: "GET", - isDeprecated: false, - parameters: [ - { name: "path", typeName: "{ petId: number }", optional: false }, - { name: "query", typeName: "{ include: string }", optional: false }, - ], - allParamsOptional: false, - isPaginatable: false, -}; - -// Operation with path: never (no actual path params) -const mockOperationWithPathNever: OperationInfo = { - methodName: "listPets", - capitalizedMethodName: "ListPets", - httpMethod: "GET", - isDeprecated: false, - parameters: [ - { name: "path", typeName: "never", optional: true }, - { name: "query", typeName: "{ limit: number }", optional: true }, - ], - allParamsOptional: true, - isPaginatable: false, -}; - -const mockFetchContext: GenerationContext = { - client: "@hey-api/client-fetch", - modelNames: [ - "Pet", - "FindPetsData", - "FindPetByIdData", - "GetPetDetailsData", - "ListPetsData", - ], - serviceNames: ["findPets", "findPetById", "getPetDetails", "listPets"], - pageParam: "page", - nextPageParam: "nextPage", - initialPageParam: "1", - version: "1.0.0", -}; - -describe("buildRouter", () => { - describe("buildWithQueryPrefetch", () => { - it("should build withQueryPrefetch helper", () => { - const result = buildWithQueryPrefetch(); - - expect(result.kind).toBe(StructureKind.VariableStatement); - expect(result.isExported).toBe(true); - expect(result.declarationKind).toBe(VariableDeclarationKind.Const); - expect(result.declarations[0].name).toBe("withQueryPrefetch"); - - const initializer = result.declarations[0].initializer as string; - expect(initializer).toContain("prefetch: () => Promise"); - expect(initializer).toContain("onMouseEnter: () => void prefetch()"); - expect(initializer).toContain("onTouchStart: () => void prefetch()"); - }); - }); - - describe("buildLoaderFactory", () => { - it("should build loader factory for operation without path param", () => { - const result = buildLoaderFactory(mockOperationNoPath, mockFetchContext); - - expect(result.kind).toBe(StructureKind.VariableStatement); - expect(result.isExported).toBe(true); - expect(result.declarationKind).toBe(VariableDeclarationKind.Const); - expect(result.declarations[0].name).toBe("loaderUseFindPets"); - - const initializer = result.declarations[0].initializer as string; - expect(initializer).toContain("queryClient: QueryClient"); - expect(initializer).toContain("clientOptions?:"); - expect(initializer).toContain("Options"); - expect(initializer).toContain("async () =>"); - expect(initializer).toContain( - "ensureUseFindPetsData(deps.queryClient, deps.clientOptions)", - ); - expect(initializer).toContain("return null"); - }); - - it("should build loader factory for operation with path param", () => { - const result = buildLoaderFactory( - mockOperationWithPath, - mockFetchContext, - ); - - expect(result.declarations[0].name).toBe("loaderUseFindPetById"); - - const initializer = result.declarations[0].initializer as string; - expect(initializer).toContain("queryClient: QueryClient"); - expect(initializer).toContain("clientOptions?:"); - expect(initializer).toContain( - 'Omit, "path">', - ); - expect(initializer).toContain("async ({ params }:"); - expect(initializer).toContain('params: FindPetByIdData["path"]'); - expect(initializer).toContain("...(deps.clientOptions ?? {})"); - expect(initializer).toContain("path: params"); - expect(initializer).toContain( - "ensureUseFindPetByIdData(deps.queryClient, options)", - ); - }); - - it("should make clientOptions required when non-path required params exist", () => { - const result = buildLoaderFactory( - mockOperationWithPathAndRequired, - mockFetchContext, - ); - - const initializer = result.declarations[0].initializer as string; - // clientOptions should NOT have ? (required) - expect(initializer).toContain("clientOptions:"); - expect(initializer).not.toMatch(/clientOptions\?:/); - }); - - it("should treat path: never as no path param", () => { - const result = buildLoaderFactory( - mockOperationWithPathNever, - mockFetchContext, - ); - - expect(result.declarations[0].name).toBe("loaderUseListPets"); - - const initializer = result.declarations[0].initializer as string; - // Should NOT have params in the inner function - expect(initializer).toContain("async () =>"); - expect(initializer).not.toContain("{ params }"); - // clientOptions should be optional since all params are optional - expect(initializer).toContain("clientOptions?:"); - }); - - it("should use unknown for missing Data type", () => { - const contextWithoutDataType: GenerationContext = { - ...mockFetchContext, - modelNames: [], - }; - - const result = buildLoaderFactory( - mockOperationNoPath, - contextWithoutDataType, - ); - - const initializer = result.declarations[0].initializer as string; - expect(initializer).toContain("Options"); - }); - }); -});