From 7c494d4b06df7d80de34aa18bdb4ce895b824cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C5=A0ilhan?= Date: Mon, 2 Dec 2024 09:22:30 +0100 Subject: [PATCH] Add suspense (#261) Co-authored-by: jan.silhan --- plugins/typescript/README.md | 6 + .../generateReactQueryComponents.test.ts | 342 ++++++++++++++++++ .../generateReactQueryComponents.ts | 62 ++-- 3 files changed, 387 insertions(+), 23 deletions(-) diff --git a/plugins/typescript/README.md b/plugins/typescript/README.md index fc6b0e4a..6dd07507 100644 --- a/plugins/typescript/README.md +++ b/plugins/typescript/README.md @@ -2,6 +2,12 @@ Collection of typescript generators & utils +## Options + +### generateSuspenseQueries + +Generate `useSuspenseQuery` wrapper along side `useQuery`. + ## Generators ### generateSchemaType diff --git a/plugins/typescript/src/generators/generateReactQueryComponents.test.ts b/plugins/typescript/src/generators/generateReactQueryComponents.test.ts index f168ec38..7687f082 100644 --- a/plugins/typescript/src/generators/generateReactQueryComponents.test.ts +++ b/plugins/typescript/src/generators/generateReactQueryComponents.test.ts @@ -124,7 +124,101 @@ describe("generateReactQueryComponents", () => { " `); }); + it("should generate a useSuspenseQuery wrapper (no parameters)", async () => { + const writeFile = jest.fn(); + const openAPIDocument: OpenAPIObject = { + openapi: "3.0.0", + info: { + title: "petshop", + version: "1.0.0", + }, + paths: { + "/pets": { + get: { + operationId: "listPets", + description: "Get all the pets", + responses: { + "200": { + description: "pet response", + content: { + "application/json": { + schema: { + type: "array", + items: { + $ref: "#/components/schemas/Pet", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + await generateReactQueryComponents( + { + openAPIDocument, + writeFile, + existsFile: () => true, + readFile: async () => "", + }, + { ...config, generateSuspenseQueries: true }, + ); + + expect(writeFile.mock.calls[0][0]).toBe("petstoreComponents.ts"); + expect(writeFile.mock.calls[0][1]).toMatchInlineSnapshot(` + "/** + * Generated by @openapi-codegen + * + * @version 1.0.0 + */ + import * as reactQuery from "@tanstack/react-query"; + import { usePetstoreContext, PetstoreContext } from "./petstoreContext"; + import type * as Fetcher from "./petstoreFetcher"; + import { petstoreFetch } from "./petstoreFetcher"; + import type * as Schemas from "./petstoreSchemas"; + + export type ListPetsError = Fetcher.ErrorWrapper; + + export type ListPetsResponse = Schemas.Pet[]; + + export type ListPetsVariables = PetstoreContext["fetcherOptions"]; + + /** + * Get all the pets + */ + export const fetchListPets = (variables: ListPetsVariables, signal?: AbortSignal) => petstoreFetch({ url: "/pets", method: "get", ...variables, signal }); + + /** + * Get all the pets + */ + export const useListPets = (variables: ListPetsVariables, options?: Omit, "queryKey" | "queryFn" | "initialData">) => { const { fetcherOptions, queryOptions, queryKeyFn } = usePetstoreContext(options); return reactQuery.useQuery({ + queryKey: queryKeyFn({ path: "/pets", operationId: "listPets", variables }), + queryFn: ({ signal }) => fetchListPets({ ...fetcherOptions, ...variables }, signal), + ...options, + ...queryOptions + }); }; + /** + * Get all the pets + */ + export const useSuspenseListPets = (variables: ListPetsVariables, options?: Omit, "queryKey" | "queryFn" | "initialData">) => { const { fetcherOptions, queryOptions, queryKeyFn } = usePetstoreContext(options); return reactQuery.useSuspenseQuery({ + queryKey: queryKeyFn({ path: "/pets", operationId: "listPets", variables }), + queryFn: ({ signal }) => fetchListPets({ ...fetcherOptions, ...variables }, signal), + ...options, + ...queryOptions + }); }; + + export type QueryOperation = { + path: "/pets"; + operationId: "listPets"; + variables: ListPetsVariables; + }; + " + `); + }); it("should generate a useQuery wrapper (with queryParams)", async () => { const writeFile = jest.fn(); const openAPIDocument: OpenAPIObject = { @@ -248,7 +342,139 @@ describe("generateReactQueryComponents", () => { " `); }); + it("should generate a useSuspenseQuery wrapper (with queryParams)", async () => { + const writeFile = jest.fn(); + const openAPIDocument: OpenAPIObject = { + openapi: "3.0.0", + info: { + title: "petshop", + version: "1.0.0", + }, + paths: { + "/pets": { + get: { + operationId: "listPets", + description: "Get all the pets", + parameters: [ + { + in: "query", + name: "breed", + description: "Filter on the dog breed", + required: true, + schema: { + type: "string", + }, + }, + { $ref: "#/components/parameters/colorParam" }, + ], + responses: { + "200": { + description: "pet response", + content: { + "application/json": { + schema: { + type: "array", + items: { + $ref: "#/components/schemas/Pet", + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + parameters: { + colorParam: { + in: "query", + description: "Color of the dog", + name: "color", + schema: { + type: "string", + enum: ["white", "black", "grey"], + }, + }, + }, + }, + }; + await generateReactQueryComponents( + { + openAPIDocument, + writeFile, + existsFile: () => true, + readFile: async () => "", + }, + { ...config, generateSuspenseQueries: true }, + ); + + expect(writeFile.mock.calls[0][0]).toBe("petstoreComponents.ts"); + expect(writeFile.mock.calls[0][1]).toMatchInlineSnapshot(` + "/** + * Generated by @openapi-codegen + * + * @version 1.0.0 + */ + import * as reactQuery from "@tanstack/react-query"; + import { usePetstoreContext, PetstoreContext } from "./petstoreContext"; + import type * as Fetcher from "./petstoreFetcher"; + import { petstoreFetch } from "./petstoreFetcher"; + import type * as Schemas from "./petstoreSchemas"; + + export type ListPetsQueryParams = { + /** + * Filter on the dog breed + */ + breed: string; + /** + * Color of the dog + */ + color?: "white" | "black" | "grey"; + }; + + export type ListPetsError = Fetcher.ErrorWrapper; + + export type ListPetsResponse = Schemas.Pet[]; + + export type ListPetsVariables = { + queryParams: ListPetsQueryParams; + } & PetstoreContext["fetcherOptions"]; + + /** + * Get all the pets + */ + export const fetchListPets = (variables: ListPetsVariables, signal?: AbortSignal) => petstoreFetch({ url: "/pets", method: "get", ...variables, signal }); + + /** + * Get all the pets + */ + export const useListPets = (variables: ListPetsVariables, options?: Omit, "queryKey" | "queryFn" | "initialData">) => { const { fetcherOptions, queryOptions, queryKeyFn } = usePetstoreContext(options); return reactQuery.useQuery({ + queryKey: queryKeyFn({ path: "/pets", operationId: "listPets", variables }), + queryFn: ({ signal }) => fetchListPets({ ...fetcherOptions, ...variables }, signal), + ...options, + ...queryOptions + }); }; + + /** + * Get all the pets + */ + export const useSuspenseListPets = (variables: ListPetsVariables, options?: Omit, "queryKey" | "queryFn" | "initialData">) => { const { fetcherOptions, queryOptions, queryKeyFn } = usePetstoreContext(options); return reactQuery.useSuspenseQuery({ + queryKey: queryKeyFn({ path: "/pets", operationId: "listPets", variables }), + queryFn: ({ signal }) => fetchListPets({ ...fetcherOptions, ...variables }, signal), + ...options, + ...queryOptions + }); }; + + export type QueryOperation = { + path: "/pets"; + operationId: "listPets"; + variables: ListPetsVariables; + }; + " + `); + }); it("should generate a useQuery wrapper (with pathParams)", async () => { const writeFile = jest.fn(); const openAPIDocument: OpenAPIObject = { @@ -355,6 +581,122 @@ describe("generateReactQueryComponents", () => { `); }); + it("should generate a useSuspenseQuery wrapper (with pathParams)", async () => { + const writeFile = jest.fn(); + const openAPIDocument: OpenAPIObject = { + openapi: "3.0.0", + info: { + title: "petshop", + version: "1.0.0", + }, + paths: { + "/pets/{pet_id}": { + get: { + operationId: "showPetById", + description: "Info for a specific pet", + parameters: [ + { + in: "path", + name: "pet_id", + description: "The id of the pet to retrieve", + required: true, + schema: { + type: "string", + }, + }, + ], + responses: { + "200": { + description: "pet response", + content: { + "application/json": { + schema: { + type: "array", + items: { + $ref: "#/components/schemas/Pet", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + await generateReactQueryComponents( + { + openAPIDocument, + writeFile, + existsFile: () => true, + readFile: async () => "", + }, + { ...config, generateSuspenseQueries: true }, + ); + + expect(writeFile.mock.calls[0][0]).toBe("petstoreComponents.ts"); + expect(writeFile.mock.calls[0][1]).toMatchInlineSnapshot(` + "/** + * Generated by @openapi-codegen + * + * @version 1.0.0 + */ + import * as reactQuery from "@tanstack/react-query"; + import { usePetstoreContext, PetstoreContext } from "./petstoreContext"; + import type * as Fetcher from "./petstoreFetcher"; + import { petstoreFetch } from "./petstoreFetcher"; + import type * as Schemas from "./petstoreSchemas"; + + export type ShowPetByIdPathParams = { + /** + * The id of the pet to retrieve + */ + petId: string; + }; + + export type ShowPetByIdError = Fetcher.ErrorWrapper; + + export type ShowPetByIdResponse = Schemas.Pet[]; + + export type ShowPetByIdVariables = { + pathParams: ShowPetByIdPathParams; + } & PetstoreContext["fetcherOptions"]; + + /** + * Info for a specific pet + */ + export const fetchShowPetById = (variables: ShowPetByIdVariables, signal?: AbortSignal) => petstoreFetch({ url: "/pets/{petId}", method: "get", ...variables, signal }); + + /** + * Info for a specific pet + */ + export const useShowPetById = (variables: ShowPetByIdVariables, options?: Omit, "queryKey" | "queryFn" | "initialData">) => { const { fetcherOptions, queryOptions, queryKeyFn } = usePetstoreContext(options); return reactQuery.useQuery({ + queryKey: queryKeyFn({ path: "/pets/{petId}", operationId: "showPetById", variables }), + queryFn: ({ signal }) => fetchShowPetById({ ...fetcherOptions, ...variables }, signal), + ...options, + ...queryOptions + }); }; + + /** + * Info for a specific pet + */ + export const useSuspenseShowPetById = (variables: ShowPetByIdVariables, options?: Omit, "queryKey" | "queryFn" | "initialData">) => { const { fetcherOptions, queryOptions, queryKeyFn } = usePetstoreContext(options); return reactQuery.useSuspenseQuery({ + queryKey: queryKeyFn({ path: "/pets/{petId}", operationId: "showPetById", variables }), + queryFn: ({ signal }) => fetchShowPetById({ ...fetcherOptions, ...variables }, signal), + ...options, + ...queryOptions + }); }; + + export type QueryOperation = { + path: "/pets/{petId}"; + operationId: "showPetById"; + variables: ShowPetByIdVariables; + }; + " + `); + }); + it("should deal with injected headers (marked them as optional)", async () => { const writeFile = jest.fn(); const openAPIDocument: OpenAPIObject = { diff --git a/plugins/typescript/src/generators/generateReactQueryComponents.ts b/plugins/typescript/src/generators/generateReactQueryComponents.ts index 99887fd3..88d07972 100644 --- a/plugins/typescript/src/generators/generateReactQueryComponents.ts +++ b/plugins/typescript/src/generators/generateReactQueryComponents.ts @@ -35,6 +35,12 @@ export type Config = ConfigBase & { * This will mark the header as optional in the component API */ injectedHeaders?: string[]; + /** + * Generate React Query components with `useSuspenseQuery` hook. + * + * @default false + */ + generateSuspenseQueries?: boolean; }; export const generateReactQueryComponents = async ( @@ -74,6 +80,8 @@ export const generateReactQueryComponents = async ( const filename = formatFilename(filenamePrefix + "-components"); + const { generateSuspenseQueries = false } = config; + const fetcherFn = c.camel(`${filenamePrefix}-fetch`); const contextTypeName = `${c.pascal(filenamePrefix)}Context`; const contextHookName = `use${c.pascal(filenamePrefix)}Context`; @@ -181,6 +189,18 @@ export const generateReactQueryComponents = async ( ); } + const hookOptions = { + operationFetcherFnName, + operation, + dataType, + errorType, + variablesType, + contextHookName, + name: `use${c.pascal(operationId)}`, + operationId, + url: route, + }; + nodes.push( ...createOperationFetcherFnNodes({ dataType, @@ -195,29 +215,23 @@ export const generateReactQueryComponents = async ( url: route, verb, name: operationFetcherFnName, - }), - ...(component === "useQuery" - ? createQueryHook({ - operationFetcherFnName, - operation, - dataType, - errorType, - variablesType, - contextHookName, - name: `use${c.pascal(operationId)}`, - operationId, - url: route, - }) - : createMutationHook({ - operationFetcherFnName, - operation, - dataType, - errorType, - variablesType, - contextHookName, - name: `use${c.pascal(operationId)}`, - })) + }) ); + + if (component === "useQuery") { + nodes.push(...createQueryHook(hookOptions)); + if (generateSuspenseQueries) { + nodes.push( + ...createQueryHook({ + ...hookOptions, + name: `useSuspense${c.pascal(operationId)}`, + useQueryIdentifier: "useSuspenseQuery", + }) + ); + } + } else { + nodes.push(...createMutationHook(hookOptions)); + } }); } ); @@ -448,6 +462,7 @@ const createQueryHook = ({ operationId, operation, url, + useQueryIdentifier = "useQuery", }: { operationFetcherFnName: string; contextHookName: string; @@ -458,6 +473,7 @@ const createQueryHook = ({ errorType: ts.TypeNode; variablesType: ts.TypeNode; operation: OperationObject; + useQueryIdentifier?: "useQuery" | "useSuspenseQuery"; }) => { const nodes: ts.Node[] = []; if (operation.description) { @@ -542,7 +558,7 @@ const createQueryHook = ({ f.createCallExpression( f.createPropertyAccessExpression( f.createIdentifier("reactQuery"), - f.createIdentifier("useQuery") + f.createIdentifier(useQueryIdentifier) ), [ dataType,