From c8cfeecc4dd50e7765c24ae1aeab0cd64afcfe90 Mon Sep 17 00:00:00 2001 From: Jack Stevenson Date: Thu, 28 Nov 2024 13:14:43 +1030 Subject: [PATCH] feat(type-safe-api): add option to generate hooks compatible with react query v5 (#889) * docs(type-safe-api): add missing typespec examples * feat(type-safe-api): add option to generate hooks compatible with react query v5 The current hooks depend on react query v4. This adds an option to generate v5 hooks. Additionally, honour the `esm` option in the generated hook libraries. --- .../type-safe-api/lambda_handlers.md | 13 + .../typescript_react_query_hooks.md | 15 + .../websocket_lambda_handlers.md | 12 + .../templates/apis.index.ejs | 6 +- .../templates/clientProvider.ejs | 12 +- .../templates/hooks.ejs | 43 +- .../templates/index.ejs | 6 +- .../templates/hooks.ejs | 2 +- .../templates/index.ejs | 4 +- .../typescript-react-query-hooks-library.ts | 18 +- packages/type-safe-api/src/project/types.ts | 8 +- .../type-safe-api-project.test.ts.snap | 2 +- .../__snapshots__/typescript-esm.test.ts.snap | 418 +++++ .../typescript-react-query-hooks.test.ts.snap | 1474 +++++++++++++++++ .../scripts/generators/typescript-esm.test.ts | 2 + .../typescript-react-query-hooks.test.ts | 36 + 16 files changed, 2049 insertions(+), 22 deletions(-) diff --git a/packages/type-safe-api/docs/developer_guides/type-safe-api/lambda_handlers.md b/packages/type-safe-api/docs/developer_guides/type-safe-api/lambda_handlers.md index 2a68daa05..55bc5cecf 100644 --- a/packages/type-safe-api/docs/developer_guides/type-safe-api/lambda_handlers.md +++ b/packages/type-safe-api/docs/developer_guides/type-safe-api/lambda_handlers.md @@ -75,6 +75,19 @@ By configuring `handlers.languages` in your `TypeSafeApiProject` and annotating } ``` +=== "TYPESPEC" + + Use the `@handler` decorator, and specify the language you wish to implement this operation in. + + ```hl_lines="3" + @get + @route("/hello") + @handler({ language: "typescript" }) + op SayHello(@query name: string): { + message: string; + }; + ``` + === "OPENAPI" Use the `x-handler` vendor extension, specifying the language you wish to implement this operation in. diff --git a/packages/type-safe-api/docs/developer_guides/type-safe-api/typescript_react_query_hooks.md b/packages/type-safe-api/docs/developer_guides/type-safe-api/typescript_react_query_hooks.md index 0cc4f30e1..d1ca8974b 100644 --- a/packages/type-safe-api/docs/developer_guides/type-safe-api/typescript_react_query_hooks.md +++ b/packages/type-safe-api/docs/developer_guides/type-safe-api/typescript_react_query_hooks.md @@ -185,6 +185,21 @@ You can generate `useInfiniteQuery` hooks instead of `useQuery` hooks for pagina } ``` +=== "TYPESPEC" + + In TypeSpec, use the `@extension` decorator to add the `x-paginated` vendor extension. + + ``` + @get + @route("/pets") + @extension("x-paginated", { inputToken: "nextToken", outputToken: "nextToken" }) + op ListPets(@query nextToken?: string): { + pets: Pet[]; + nextToken?: string; + };` + ``` + + === "OPENAPI" In OpenAPI, use the `x-paginaged` vendor extension in your operation, making sure both `inputToken` and `outputToken` are specified: diff --git a/packages/type-safe-api/docs/developer_guides/type-safe-api/websocket_lambda_handlers.md b/packages/type-safe-api/docs/developer_guides/type-safe-api/websocket_lambda_handlers.md index 4a029dea5..26489f321 100644 --- a/packages/type-safe-api/docs/developer_guides/type-safe-api/websocket_lambda_handlers.md +++ b/packages/type-safe-api/docs/developer_guides/type-safe-api/websocket_lambda_handlers.md @@ -46,6 +46,18 @@ By configuring `handlers.languages` in your `TypeSafeWebSocketApiProject` and an } ``` +=== "TYPESPEC" + + Use the `@handler` decorator, and specify the language you wish to implement this operation in. + + ```tsp hl_lines="1-2" + @async({ direction: "client_to_server" }) + @handler({ language: "typescript" }) + op SayHello( + name: string, + ): void; + ``` + === "OPENAPI" Use the `x-handler` vendor extension, specifying the language you wish to implement this operation in. diff --git a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/apis.index.ejs b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/apis.index.ejs index 862c57706..c8a1b9640 100644 --- a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/apis.index.ejs +++ b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/apis.index.ejs @@ -8,7 +8,7 @@ ###/TSAPI_WRITE_FILE###/* tslint:disable */ /* eslint-disable */ <%_ services.forEach((service) => { _%> -export * from './<%- service.className %>'; -export * from './<%- service.className %>Hooks'; -export * from './<%- service.className %>ClientProvider'; +export * from './<%- service.className %><%_ if (metadata.esm) { _%>.js<%_ } _%>'; +export * from './<%- service.className %>Hooks<%_ if (metadata.esm) { _%>.js<%_ } _%>'; +export * from './<%- service.className %>ClientProvider<%_ if (metadata.esm) { _%>.js<%_ } _%>'; <%_ }); _%> diff --git a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/clientProvider.ejs b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/clientProvider.ejs index df7327764..4d4c3fd66 100644 --- a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/clientProvider.ejs +++ b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/clientProvider.ejs @@ -11,11 +11,12 @@ import { QueryClient, QueryClientProvider, } from "@tanstack/react-query"; -import { <%- service.className %> } from "./<%- service.className %>"; -import { <%- service.className %>ClientContext } from "./<%- service.className %>Hooks"; +import { <%- service.className %> } from "./<%- service.className %><%_ if (metadata.esm) { _%>.js<%_ } _%>"; +import { <%- service.className %>ClientContext } from "./<%- service.className %>Hooks<%_ if (metadata.esm) { _%>.js<%_ } _%>"; const queryClient = new QueryClient(); +<%_ if (!metadata.queryV5) { _%> /** * Default QueryClient context for <%- service.className %> */ @@ -23,13 +24,16 @@ export const <%- service.className %>DefaultContext = React.createContext /** * Properties for the <%- service.className %>ClientProvider */ export interface <%- service.className %>ClientProviderProps { readonly apiClient: <%- service.className %>; readonly client?: QueryClient; + <%_ if (!metadata.queryV5) { _%> readonly context?: React.Context; + <%_ } _%> readonly children?: React.ReactNode; } @@ -40,11 +44,13 @@ export interface <%- service.className %>ClientProviderProps { export const <%- service.className %>ClientProvider = ({ apiClient, client = queryClient, + <%_ if (!metadata.queryV5) { _%> context = <%- service.className %>DefaultContext, + <%_ } _%> children, }: <%- service.className %>ClientProviderProps): JSX.Element => { return ( - + context={context}<% } %>> <<%- service.className %>ClientContext.Provider value={apiClient}> {children} ClientContext.Provider> diff --git a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/hooks.ejs b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/hooks.ejs index e2ca7234d..93d02d406 100644 --- a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/hooks.ejs +++ b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/hooks.ejs @@ -12,7 +12,7 @@ import type { <%_ service.modelImports.forEach((modelImport) => { _%> <%- modelImport %>, <%_ }); _%> -} from '../models'; +} from '../models<%_ if (metadata.esm) { _%>/index.js<%_ } _%>'; <%_ } _%> // Import request parameter interfaces import { @@ -21,13 +21,18 @@ import { <%- operation.operationIdPascalCase %>Request, <%_ } _%> <%_ }); _%> -} from '..'; +} from '..<%_ if (metadata.esm) { _%>/index.js<%_ } _%>'; -import { ResponseError } from '../runtime'; -import { <%- service.className %> } from './<%- service.className %>'; -import { <%- service.className %>DefaultContext } from "./<%- service.className %>ClientProvider"; +import { ResponseError } from '../runtime<%_ if (metadata.esm) { _%>.js<%_ } _%>'; +import { <%- service.className %> } from './<%- service.className %><%_ if (metadata.esm) { _%>.js<%_ } _%>'; +<%_ if (!metadata.queryV5) { _%> +import { <%- service.className %>DefaultContext } from "./<%- service.className %>ClientProvider<%_ if (metadata.esm) { _%>.js<%_ } _%>"; +<%_ } _%> import { + <%_ if (metadata.queryV5) { _%> + InitialPageParam, + <%_ } _%> useQuery, UseQueryResult, UseQueryOptions, @@ -61,17 +66,28 @@ export const use<%- operation.operationIdPascalCase %> = 0) { _%> params: <%- operation.operationIdPascalCase %>Request, <%_ } _%> - options?: Omit, TError>, 'queryKey' | 'queryFn' | 'getNextPageParam'> + options?: Omit, TError>, 'queryKey' | 'queryFn' | 'getNextPageParam'<% if (metadata.queryV5) { %> | 'initialPageParam'<% } %>><% if (metadata.queryV5) { %> & ( + InitialPageParam<<%- operation.operationIdPascalCase %>Request['<%- paginationInputParam.typescriptName %>']> + )<% } %> ): UseInfiniteQueryResult<<%- resultType %>, TError> => { const api = useContext(<%- service.className %>ClientContext); if (!api) { throw NO_API_ERROR; } + <%_ if (metadata.queryV5) { _%> + return useInfiniteQuery({ + queryKey: ["<%- operation.name %>"<% if (operation.parameters.length > 0) { %>, params<% } %>], + queryFn: ({ pageParam }) => api.<%- operation.name %>({ <% if (operation.parameters.length > 0) { %>...params, <% } %><%- paginationInputParam.typescriptName %>: pageParam as any }), + getNextPageParam: (response) => response.<%- pagination.outputToken %>, + ...options, + }); + <%_ } else { _%> return useInfiniteQuery(["<%- operation.name %>"<% if (operation.parameters.length > 0) { %>, params<% } %>], ({ pageParam }) => api.<%- operation.name %>({ <% if (operation.parameters.length > 0) { %>...params, <% } %><%- paginationInputParam.typescriptName %>: pageParam }), { getNextPageParam: (response) => response.<%- pagination.outputToken %>, context: <%- service.className %>DefaultContext, ...options as any, }); + <%_ } _%> }; <%_ } else { _%> /** @@ -87,10 +103,18 @@ export const use<%- operation.operationIdPascalCase %> = + return useQuery({ + queryKey: ["<%- operation.name %>"<% if (operation.parameters.length > 0) { %>, params<% } %>], + queryFn: () => api.<%- operation.name %>(<% if (operation.parameters.length > 0) { %>params<% } %>), + ...options, + }); + <%_ } else { _%> return useQuery(["<%- operation.name %>"<% if (operation.parameters.length > 0) { %>, params<% } %>], () => api.<%- operation.name %>(<% if (operation.parameters.length > 0) { %>params<% } %>), { context: <%- service.className %>DefaultContext, ...options, }); + <%_ } _%> }; <%_ } _%> <%_ } else { _%> @@ -104,10 +128,17 @@ export const use<%- operation.operationIdPascalCase %> = + return useMutation({ + mutationFn: (<% if (operation.parameters.length > 0) { %>params: <%- operation.operationIdPascalCase %>Request<% } %>) => api.<%- operation.name %>(<% if (operation.parameters.length > 0) { %>params<% } %>), + ...options, + }); + <%_ } else { _%> return useMutation((<% if (operation.parameters.length > 0) { %>params: <%- operation.operationIdPascalCase %>Request<% } %>) => api.<%- operation.name %>(<% if (operation.parameters.length > 0) { %>params<% } %>), { context: <%- service.className %>DefaultContext, ...options, }); + <%_ } _%> }; <%_ } _%> diff --git a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/index.ejs b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/index.ejs index c4a653a34..aa6ad7986 100644 --- a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/index.ejs +++ b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-react-query-hooks/templates/index.ejs @@ -7,6 +7,6 @@ } ###/TSAPI_WRITE_FILE###/* tslint:disable */ /* eslint-disable */ -export * from './runtime'; -export * from './apis'; -export * from './models'; \ No newline at end of file +export * from './runtime<%_ if (metadata.esm) { _%>.js<%_ } _%>'; +export * from './apis<%_ if (metadata.esm) { _%>/index.js<%_ } _%>'; +export * from './models<%_ if (metadata.esm) { _%>/index.js<%_ } _%>'; \ No newline at end of file diff --git a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-websocket-hooks/templates/hooks.ejs b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-websocket-hooks/templates/hooks.ejs index eea8b15c0..6bda3767a 100644 --- a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-websocket-hooks/templates/hooks.ejs +++ b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-websocket-hooks/templates/hooks.ejs @@ -15,7 +15,7 @@ } from "<%- metadata.websocketClientPackageName %>"; import { <%- serviceClassName %>WebSocketClientContext, -} from "./provider"; +} from "./provider<%_ if (metadata.esm) { _%>.js<%_ } _%>"; import { useContext, useEffect, useCallback, DependencyList } from "react"; const NO_CLIENT_ERROR = new Error(`<%- serviceClassName %>WebSocketClient is missing. Please ensure you have instantiated the <%- serviceClassName %>WebSocketClientProvider with a client instance.`); diff --git a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-websocket-hooks/templates/index.ejs b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-websocket-hooks/templates/index.ejs index 0cb890330..601cd0f5b 100644 --- a/packages/type-safe-api/scripts/type-safe-api/generators/typescript-websocket-hooks/templates/index.ejs +++ b/packages/type-safe-api/scripts/type-safe-api/generators/typescript-websocket-hooks/templates/index.ejs @@ -8,5 +8,5 @@ } ###/TSAPI_WRITE_FILE###/* tslint:disable */ /* eslint-disable */ -export * from './hooks/hooks'; -export * from './hooks/provider'; +export * from './hooks/hooks<%_ if (metadata.esm) { _%>.js<%_ } _%>'; +export * from './hooks/provider<%_ if (metadata.esm) { _%>.js<%_ } _%>'; diff --git a/packages/type-safe-api/src/project/codegen/library/typescript-react-query-hooks-library.ts b/packages/type-safe-api/src/project/codegen/library/typescript-react-query-hooks-library.ts index fdd51df2c..f08cd32d1 100644 --- a/packages/type-safe-api/src/project/codegen/library/typescript-react-query-hooks-library.ts +++ b/packages/type-safe-api/src/project/codegen/library/typescript-react-query-hooks-library.ts @@ -12,12 +12,20 @@ import { CodegenOptions } from "../components/utils"; * Configuration for the generated typescript client project */ export interface GeneratedTypescriptReactQueryHooksProjectOptions - extends GeneratedTypescriptLibraryProjectOptions {} + extends GeneratedTypescriptLibraryProjectOptions { + /** + * Set to true to use @tanstack/react-query version 5.x + * @default false - @tanstack/react-query version 4.x is used + */ + readonly useReactQueryV5?: boolean; +} /** * Typescript project containing generated react-query hooks */ export class TypescriptReactQueryHooksLibrary extends GeneratedTypescriptLibraryProject { + private readonly useReactQueryV5?: boolean; + constructor(options: GeneratedTypescriptReactQueryHooksProjectOptions) { super({ ...options, @@ -27,9 +35,14 @@ export class TypescriptReactQueryHooksLibrary extends GeneratedTypescriptLibrary }, }, }); + this.useReactQueryV5 = options.useReactQueryV5; // Add dependencies on react-query and react - this.addDeps("@tanstack/react-query@^4"); // Pin at 4 for now - requires generated code updates to upgrade to 5 + if (this.useReactQueryV5) { + this.addDeps("@tanstack/react-query@^5"); + } else { + this.addDeps("@tanstack/react-query@^4"); + } this.addDevDeps("react", "@types/react"); this.addPeerDeps("react"); } @@ -44,6 +57,7 @@ export class TypescriptReactQueryHooksLibrary extends GeneratedTypescriptLibrary ], metadata: { srcDir: this.srcdir, + queryV5: !!this.useReactQueryV5, }, }; } diff --git a/packages/type-safe-api/src/project/types.ts b/packages/type-safe-api/src/project/types.ts index 58df0c14e..5c720b8e9 100644 --- a/packages/type-safe-api/src/project/types.ts +++ b/packages/type-safe-api/src/project/types.ts @@ -254,7 +254,13 @@ export interface GeneratedJavaHandlersOptions */ export interface GeneratedTypeScriptReactQueryHooksOptions extends TypeScriptProjectOptions, - GeneratedProjectOptions {} + GeneratedProjectOptions { + /** + * Set to true to use @tanstack/react-query version 5.x + * @default false - @tanstack/react-query version 4.x is used + */ + readonly useReactQueryV5?: boolean; +} /** * Options for configuring a generated typescript websocket client library project diff --git a/packages/type-safe-api/test/project/__snapshots__/type-safe-api-project.test.ts.snap b/packages/type-safe-api/test/project/__snapshots__/type-safe-api-project.test.ts.snap index 940833bd6..7e7d4bd6f 100644 --- a/packages/type-safe-api/test/project/__snapshots__/type-safe-api-project.test.ts.snap +++ b/packages/type-safe-api/test/project/__snapshots__/type-safe-api-project.test.ts.snap @@ -31045,7 +31045,7 @@ strict-peer-dependencies=false "name": "generate", "steps": [ { - "exec": "npx --yes -p @aws/pdk@$AWS_PDK_VERSION type-safe-api generate --specPath ../../../model/.api.json --outputPath . --templateDirs "typescript/templates/client" "typescript-react-query-hooks" --metadata '{"srcDir":"src"}'", + "exec": "npx --yes -p @aws/pdk@$AWS_PDK_VERSION type-safe-api generate --specPath ../../../model/.api.json --outputPath . --templateDirs "typescript/templates/client" "typescript-react-query-hooks" --metadata '{"srcDir":"src","queryV5":false}'", }, ], }, diff --git a/packages/type-safe-api/test/scripts/generators/__snapshots__/typescript-esm.test.ts.snap b/packages/type-safe-api/test/scripts/generators/__snapshots__/typescript-esm.test.ts.snap index 073a13a4c..8ba19c63d 100644 --- a/packages/type-safe-api/test/scripts/generators/__snapshots__/typescript-esm.test.ts.snap +++ b/packages/type-safe-api/test/scripts/generators/__snapshots__/typescript-esm.test.ts.snap @@ -4143,6 +4143,296 @@ describe('TypescriptTwo', () => { } `; +exports[`TypeScript ESM Generator Tests Generates typescript-react-query-hooks with ESM compatible syntax 1`] = ` +{ + ".tsapi-manifest": "README.md +src/apis/index.ts +src/apis/DefaultApiClientProvider.tsx +src/apis/DefaultApiHooks.ts +src/index.ts", + "README.md": "# TypeScript React Query Hooks + +This project contains [react-query](https://tanstack.com/query/latest) hooks for interacting with your API. + +## Usage + +First, make sure you add a dependency on the generated hooks library, eg in your \`.projenrc\`: + +\`\`\`ts +const api = new TypeSafeApiProject({ ... }); + +new CloudscapeReactTsWebsite({ + ..., + deps: [ + ... + api.library.typescriptReactQueryHooks!.package.packageName, + ], +}); +\`\`\` + +Next, create an instance of the API client (making sure to set the base URL and fetch instance). For example: + +\`\`\`ts +export const useDefaultApiClient = () => useMemo(() => new DefaultApi(new Configuration({ + basePath: 'https://example123.execute-api.ap-southeast-2.amazonaws.com/prod', + fetchApi: window.fetch.bind(window), +})), []); +\`\`\` + +Note that if you are using the [Cloudscape React Website](https://github.com/aws/aws-pdk/tree/mainline/packages/cloudscape-react-ts-website) with [AWS NorthStar](https://aws.github.io/aws-northstar/) and IAM (Sigv4) Auth for your API, you can use NorthStar's [\`useSigv4Client()\` hook](https://aws.github.io/aws-northstar/?path=/story/components-cognitoauth-sigv4client-docs--page) to create +an instance of \`fetch\` which will sign requests with the logged in user's credentials. For example: + +\`\`\`ts +export const useDefaultApiClient = () => { + const client = useSigv4Client(); + return useMemo(() => new DefaultApi(new Configuration({ + basePath: 'https://example123.execute-api.ap-southeast-2.amazonaws.com/prod', + fetchApi: client, + })), [client]); +}; +\`\`\` + +Next, instantiate the client provider above where you would like to use the hooks in your component hierarchy (such as above your router). For example: + +\`\`\`tsx +const api = useDefaultApiClient(); + +return ( + + { /* Components within the provider may make use of the hooks */ } + +); +\`\`\` + +Finally, you can import and use your generated hooks. For example: + +\`\`\`tsx +export const MyComponent: FC = () => { + const sayHello = useSayHello({ name: 'World' }); + + return sayHello.isLoading ? ( +

Loading...

+ ) : sayHello.isError ? ( +

Error!

+ ) : ( +

{sayHello.data.message}

+ ); +}; +\`\`\` + +## Custom Error Type + +If you use middleware in your client for error handling and throw different errors, you can override the error type +when you use a hook, for example: + +\`\`\`ts +const sayHello = useSayHello({ name: 'World' }); +\`\`\` +", + "src/apis/DefaultApiClientProvider.tsx": "import * as React from "react"; +import { + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { DefaultApi } from "./DefaultApi.js"; +import { DefaultApiClientContext } from "./DefaultApiHooks.js"; + +const queryClient = new QueryClient(); + +/** + * Default QueryClient context for DefaultApi + */ +export const DefaultApiDefaultContext = React.createContext( + undefined +); + +/** + * Properties for the DefaultApiClientProvider + */ +export interface DefaultApiClientProviderProps { + readonly apiClient: DefaultApi; + readonly client?: QueryClient; + readonly context?: React.Context; + readonly children?: React.ReactNode; +} + +/** + * Provider for the API Client and Query Client used by the hooks. + * This must parent any components which make use of the hooks. + */ +export const DefaultApiClientProvider = ({ + apiClient, + client = queryClient, + context = DefaultApiDefaultContext, + children, +}: DefaultApiClientProviderProps): JSX.Element => { + return ( + + + {children} + + + ); +}; +", + "src/apis/DefaultApiHooks.ts": "// Import models +import type { + BadRequestErrorResponseContent, + InternalFailureErrorResponseContent, + NotAuthorizedErrorResponseContent, + SuccessResponseContent, +} from '../models/index.js'; +// Import request parameter interfaces +import { + JavaOneRequest, + JavaTwoRequest, + PythonOneRequest, + PythonTwoRequest, + TypescriptOneRequest, + TypescriptTwoRequest, +} from '../index.js'; + +import { ResponseError } from '../runtime.js'; +import { DefaultApi } from './DefaultApi.js'; +import { DefaultApiDefaultContext } from "./DefaultApiClientProvider.js"; + +import { + useQuery, + UseQueryResult, + UseQueryOptions, + useInfiniteQuery, + UseInfiniteQueryResult, + UseInfiniteQueryOptions, + useMutation, + UseMutationOptions, + UseMutationResult +} from "@tanstack/react-query"; +import { createContext, useContext } from "react"; + +/** + * Context for the API client used by the hooks. + */ +export const DefaultApiClientContext = createContext(undefined); + +const NO_API_ERROR = new Error(\`DefaultApi client missing. Please ensure you have instantiated the DefaultApiClientProvider with a client instance.\`); + +/** + * useQuery hook for the JavaOne operation + */ +export const useJavaOne = ( + params: JavaOneRequest, + options?: Omit, 'queryKey' | 'queryFn'> +): UseQueryResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useQuery(["javaOne", params], () => api.javaOne(params), { + context: DefaultApiDefaultContext, + ...options, + }); +}; + +/** + * useQuery hook for the JavaTwo operation + */ +export const useJavaTwo = ( + params: JavaTwoRequest, + options?: Omit, 'queryKey' | 'queryFn'> +): UseQueryResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useQuery(["javaTwo", params], () => api.javaTwo(params), { + context: DefaultApiDefaultContext, + ...options, + }); +}; + +/** + * useQuery hook for the PythonOne operation + */ +export const usePythonOne = ( + params: PythonOneRequest, + options?: Omit, 'queryKey' | 'queryFn'> +): UseQueryResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useQuery(["pythonOne", params], () => api.pythonOne(params), { + context: DefaultApiDefaultContext, + ...options, + }); +}; + +/** + * useQuery hook for the PythonTwo operation + */ +export const usePythonTwo = ( + params: PythonTwoRequest, + options?: Omit, 'queryKey' | 'queryFn'> +): UseQueryResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useQuery(["pythonTwo", params], () => api.pythonTwo(params), { + context: DefaultApiDefaultContext, + ...options, + }); +}; + +/** + * useQuery hook for the TypescriptOne operation + */ +export const useTypescriptOne = ( + params: TypescriptOneRequest, + options?: Omit, 'queryKey' | 'queryFn'> +): UseQueryResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useQuery(["typescriptOne", params], () => api.typescriptOne(params), { + context: DefaultApiDefaultContext, + ...options, + }); +}; + +/** + * useQuery hook for the TypescriptTwo operation + */ +export const useTypescriptTwo = ( + params: TypescriptTwoRequest, + options?: Omit, 'queryKey' | 'queryFn'> +): UseQueryResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useQuery(["typescriptTwo", params], () => api.typescriptTwo(params), { + context: DefaultApiDefaultContext, + ...options, + }); +}; + +", + "src/apis/index.ts": "/* tslint:disable */ +/* eslint-disable */ +export * from './DefaultApi.js'; +export * from './DefaultApiHooks.js'; +export * from './DefaultApiClientProvider.js'; +", + "src/index.ts": "/* tslint:disable */ +/* eslint-disable */ +export * from './runtime.js'; +export * from './apis/index.js'; +export * from './models/index.js';", +} +`; + exports[`TypeScript ESM Generator Tests Generates typescript-websocket-client with ESM compatible syntax 1`] = ` { ".tsapi-manifest": "src/client/client.ts @@ -4618,3 +4908,131 @@ export * from './client/client.js'; ", } `; + +exports[`TypeScript ESM Generator Tests Generates typescript-websocket-hooks with ESM compatible syntax 1`] = ` +{ + ".tsapi-manifest": "src/hooks/hooks.tsx +src/index.ts +src/hooks/provider.tsx", + "src/hooks/hooks.tsx": "import { + Request, + DefaultApiWebSocketClient, +} from ""; +import { + DefaultApiWebSocketClientContext, +} from "./provider.js"; +import { useContext, useEffect, useCallback, DependencyList } from "react"; + +const NO_CLIENT_ERROR = new Error(\`DefaultApiWebSocketClient is missing. Please ensure you have instantiated the DefaultApiWebSocketClientProvider with a client instance.\`); + +/** + * Hook to retrieve the websocket client from the context + */ +export const useDefaultApiWebSocketClient = () => { + const client = useContext(DefaultApiWebSocketClientContext); + if (!client) { + throw NO_CLIENT_ERROR; + } + return client; +}; + +/** + * Listen to JavaOne messages from the server + * Provided callback should use the useCallback hook to memoise the function + */ +export const useOnJavaOne = (callback: (input: Request) => void, deps: DependencyList) => { + const client = useDefaultApiWebSocketClient(); + const cb = useCallback(callback, deps); + useEffect(() => { + return client.onJavaOne(cb); + }, [client, cb]); +}; + +/** + * Listen to JavaTwo messages from the server + * Provided callback should use the useCallback hook to memoise the function + */ +export const useOnJavaTwo = (callback: (input: Request) => void, deps: DependencyList) => { + const client = useDefaultApiWebSocketClient(); + const cb = useCallback(callback, deps); + useEffect(() => { + return client.onJavaTwo(cb); + }, [client, cb]); +}; + +/** + * Listen to PythonOne messages from the server + * Provided callback should use the useCallback hook to memoise the function + */ +export const useOnPythonOne = (callback: (input: Request) => void, deps: DependencyList) => { + const client = useDefaultApiWebSocketClient(); + const cb = useCallback(callback, deps); + useEffect(() => { + return client.onPythonOne(cb); + }, [client, cb]); +}; + +/** + * Listen to PythonTwo messages from the server + * Provided callback should use the useCallback hook to memoise the function + */ +export const useOnPythonTwo = (callback: (input: Request) => void, deps: DependencyList) => { + const client = useDefaultApiWebSocketClient(); + const cb = useCallback(callback, deps); + useEffect(() => { + return client.onPythonTwo(cb); + }, [client, cb]); +}; + +/** + * Listen to TypescriptOne messages from the server + * Provided callback should use the useCallback hook to memoise the function + */ +export const useOnTypescriptOne = (callback: (input: Request) => void, deps: DependencyList) => { + const client = useDefaultApiWebSocketClient(); + const cb = useCallback(callback, deps); + useEffect(() => { + return client.onTypescriptOne(cb); + }, [client, cb]); +}; + +/** + * Listen to TypescriptTwo messages from the server + * Provided callback should use the useCallback hook to memoise the function + */ +export const useOnTypescriptTwo = (callback: (input: Request) => void, deps: DependencyList) => { + const client = useDefaultApiWebSocketClient(); + const cb = useCallback(callback, deps); + useEffect(() => { + return client.onTypescriptTwo(cb); + }, [client, cb]); +}; + +", + "src/hooks/provider.tsx": "import { + DefaultApiWebSocketClient, +} from ""; +import React, { createContext, ReactNode, FC } from "react"; + +export const DefaultApiWebSocketClientContext = createContext(undefined); + +export interface DefaultApiWebSocketClientProviderProps { + readonly client: DefaultApiWebSocketClient; + readonly children?: ReactNode; +} + +export const DefaultApiWebSocketClientProvider: FC = (props) => { + return ( + + {props.children} + + ); +}; +", + "src/index.ts": "/* tslint:disable */ +/* eslint-disable */ +export * from './hooks/hooks.js'; +export * from './hooks/provider.js'; +", +} +`; diff --git a/packages/type-safe-api/test/scripts/generators/__snapshots__/typescript-react-query-hooks.test.ts.snap b/packages/type-safe-api/test/scripts/generators/__snapshots__/typescript-react-query-hooks.test.ts.snap index b4108dae7..ee0862b74 100644 --- a/packages/type-safe-api/test/scripts/generators/__snapshots__/typescript-react-query-hooks.test.ts.snap +++ b/packages/type-safe-api/test/scripts/generators/__snapshots__/typescript-react-query-hooks.test.ts.snap @@ -2489,3 +2489,1477 @@ export class TextApiResponse { ", } `; + +exports[`Typescript React Query Hooks Code Generation Script Unit Tests Generates react query v5 hooks 1`] = ` +{ + ".tsapi-manifest": "src/apis/DefaultApi.ts +src/apis/index.ts +src/models/index.ts +src/models/model-utils.ts +src/models/ApiError.ts +src/models/PaginatedGet200Response.ts +src/models/RegularGet200Response.ts +src/models/TestRequest.ts +src/models/TestResponse.ts +src/models/TestResponseMessagesInner.ts +src/runtime.ts +README.md +src/apis/index.ts +src/apis/DefaultApiClientProvider.tsx +src/apis/DefaultApiHooks.ts +src/index.ts", + "README.md": "# TypeScript React Query Hooks + +This project contains [react-query](https://tanstack.com/query/latest) hooks for interacting with your API. + +## Usage + +First, make sure you add a dependency on the generated hooks library, eg in your \`.projenrc\`: + +\`\`\`ts +const api = new TypeSafeApiProject({ ... }); + +new CloudscapeReactTsWebsite({ + ..., + deps: [ + ... + api.library.typescriptReactQueryHooks!.package.packageName, + ], +}); +\`\`\` + +Next, create an instance of the API client (making sure to set the base URL and fetch instance). For example: + +\`\`\`ts +export const useDefaultApiClient = () => useMemo(() => new DefaultApi(new Configuration({ + basePath: 'https://example123.execute-api.ap-southeast-2.amazonaws.com/prod', + fetchApi: window.fetch.bind(window), +})), []); +\`\`\` + +Note that if you are using the [Cloudscape React Website](https://github.com/aws/aws-pdk/tree/mainline/packages/cloudscape-react-ts-website) with [AWS NorthStar](https://aws.github.io/aws-northstar/) and IAM (Sigv4) Auth for your API, you can use NorthStar's [\`useSigv4Client()\` hook](https://aws.github.io/aws-northstar/?path=/story/components-cognitoauth-sigv4client-docs--page) to create +an instance of \`fetch\` which will sign requests with the logged in user's credentials. For example: + +\`\`\`ts +export const useDefaultApiClient = () => { + const client = useSigv4Client(); + return useMemo(() => new DefaultApi(new Configuration({ + basePath: 'https://example123.execute-api.ap-southeast-2.amazonaws.com/prod', + fetchApi: client, + })), [client]); +}; +\`\`\` + +Next, instantiate the client provider above where you would like to use the hooks in your component hierarchy (such as above your router). For example: + +\`\`\`tsx +const api = useDefaultApiClient(); + +return ( + + { /* Components within the provider may make use of the hooks */ } + +); +\`\`\` + +Finally, you can import and use your generated hooks. For example: + +\`\`\`tsx +export const MyComponent: FC = () => { + const sayHello = useSayHello({ name: 'World' }); + + return sayHello.isLoading ? ( +

Loading...

+ ) : sayHello.isError ? ( +

Error!

+ ) : ( +

{sayHello.data.message}

+ ); +}; +\`\`\` + +## Custom Error Type + +If you use middleware in your client for error handling and throw different errors, you can override the error type +when you use a hook, for example: + +\`\`\`ts +const sayHello = useSayHello({ name: 'World' }); +\`\`\` +", + "src/apis/DefaultApi.ts": "/* tslint:disable */ +/* eslint-disable */ +/** + * Example API + * + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated. + * Do not edit the class manually. + */ + +import * as runtime from '../runtime'; +import type { + ApiError, + PaginatedGet200Response, + RegularGet200Response, + TestRequest, + TestResponse, +} from '../models'; +import { + ApiErrorFromJSON, + ApiErrorToJSON, + PaginatedGet200ResponseFromJSON, + PaginatedGet200ResponseToJSON, + RegularGet200ResponseFromJSON, + RegularGet200ResponseToJSON, + TestRequestFromJSON, + TestRequestToJSON, + TestResponseFromJSON, + TestResponseToJSON, +} from '../models'; + +export interface AnyRequestResponseRequest { + body?: any | null; +} + + +export interface MediaTypesRequest { + body: Blob; +} + +export interface OperationOneRequest { + param1: string; + param2: Array; + param3: number; + pathParam: string; + testRequest: TestRequest; + param4?: string; +} + +export interface PaginatedGetRequest { + inputNextToken: string; +} + + + +/** + * + */ +export class DefaultApi extends runtime.BaseAPI { + /** + * + */ + async anyRequestResponseRaw(requestParameters: AnyRequestResponseRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + + const response = await this.request({ + path: \`/any-request-response\`, + method: 'PUT', + headers: headerParameters, + query: queryParameters, + body: requestParameters.body as any, + }, initOverrides); + + return new runtime.TextApiResponse(response) as any; + } + + /** + * + */ + async anyRequestResponse(requestParameters: AnyRequestResponseRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.anyRequestResponseRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * + */ + async emptyRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + + const headerParameters: runtime.HTTPHeaders = {}; + + + + const response = await this.request({ + path: \`/empty-response\`, + method: 'PUT', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.VoidApiResponse(response); + } + + /** + * + */ + async empty(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + await this.emptyRaw(initOverrides); + } + + /** + * + */ + async mediaTypesRaw(requestParameters: MediaTypesRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters.body === null || requestParameters.body === undefined) { + throw new runtime.RequiredError('body','Required parameter requestParameters.body was null or undefined when calling mediaTypes.'); + } + + const queryParameters: any = {}; + + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/pdf'; + + + const response = await this.request({ + path: \`/different-media-type\`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: requestParameters.body as any, + }, initOverrides); + + return new runtime.TextApiResponse(response) as any; + } + + /** + * + */ + async mediaTypes(requestParameters: MediaTypesRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.mediaTypesRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * + */ + async operationOneRaw(requestParameters: OperationOneRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters.param1 === null || requestParameters.param1 === undefined) { + throw new runtime.RequiredError('param1','Required parameter requestParameters.param1 was null or undefined when calling operationOne.'); + } + + if (requestParameters.param2 === null || requestParameters.param2 === undefined) { + throw new runtime.RequiredError('param2','Required parameter requestParameters.param2 was null or undefined when calling operationOne.'); + } + + if (requestParameters.param3 === null || requestParameters.param3 === undefined) { + throw new runtime.RequiredError('param3','Required parameter requestParameters.param3 was null or undefined when calling operationOne.'); + } + + if (requestParameters.pathParam === null || requestParameters.pathParam === undefined) { + throw new runtime.RequiredError('pathParam','Required parameter requestParameters.pathParam was null or undefined when calling operationOne.'); + } + + if (requestParameters.testRequest === null || requestParameters.testRequest === undefined) { + throw new runtime.RequiredError('testRequest','Required parameter requestParameters.testRequest was null or undefined when calling operationOne.'); + } + + const queryParameters: any = {}; + + if (requestParameters.param1 !== undefined) { + queryParameters['param1'] = requestParameters.param1; + } + + if (requestParameters.param2) { + queryParameters['param2'] = requestParameters.param2; + } + + if (requestParameters.param3 !== undefined) { + queryParameters['param3'] = requestParameters.param3; + } + + if (requestParameters.param4 !== undefined) { + queryParameters['param4'] = requestParameters.param4; + } + + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + + const response = await this.request({ + path: \`/path/{pathParam}\`.replace(\`{\${"pathParam"}}\`, encodeURIComponent(String(requestParameters.pathParam))), + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: TestRequestToJSON(requestParameters.testRequest), + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => TestResponseFromJSON(jsonValue)); + } + + /** + * + */ + async operationOne(requestParameters: OperationOneRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.operationOneRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * + */ + async paginatedGetRaw(requestParameters: PaginatedGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters.inputNextToken === null || requestParameters.inputNextToken === undefined) { + throw new runtime.RequiredError('inputNextToken','Required parameter requestParameters.inputNextToken was null or undefined when calling paginatedGet.'); + } + + const queryParameters: any = {}; + + if (requestParameters.inputNextToken !== undefined) { + queryParameters['inputNextToken'] = requestParameters.inputNextToken; + } + + + const headerParameters: runtime.HTTPHeaders = {}; + + + + const response = await this.request({ + path: \`/paginated-get\`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => PaginatedGet200ResponseFromJSON(jsonValue)); + } + + /** + * + */ + async paginatedGet(requestParameters: PaginatedGetRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.paginatedGetRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * + */ + async regularGetRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + + const headerParameters: runtime.HTTPHeaders = {}; + + + + const response = await this.request({ + path: \`/regular-get\`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => RegularGet200ResponseFromJSON(jsonValue)); + } + + /** + * + */ + async regularGet(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.regularGetRaw(initOverrides); + return await response.value(); + } + + /** + * + */ + async withoutOperationIdDeleteRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + + const headerParameters: runtime.HTTPHeaders = {}; + + + + const response = await this.request({ + path: \`/without-operation-id\`, + method: 'DELETE', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => TestResponseFromJSON(jsonValue)); + } + + /** + * + */ + async withoutOperationIdDelete(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.withoutOperationIdDeleteRaw(initOverrides); + return await response.value(); + } + +} + +", + "src/apis/DefaultApiClientProvider.tsx": "import * as React from "react"; +import { + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; +import { DefaultApi } from "./DefaultApi"; +import { DefaultApiClientContext } from "./DefaultApiHooks"; + +const queryClient = new QueryClient(); + +/** + * Properties for the DefaultApiClientProvider + */ +export interface DefaultApiClientProviderProps { + readonly apiClient: DefaultApi; + readonly client?: QueryClient; + readonly children?: React.ReactNode; +} + +/** + * Provider for the API Client and Query Client used by the hooks. + * This must parent any components which make use of the hooks. + */ +export const DefaultApiClientProvider = ({ + apiClient, + client = queryClient, + children, +}: DefaultApiClientProviderProps): JSX.Element => { + return ( + + + {children} + + + ); +}; +", + "src/apis/DefaultApiHooks.ts": "// Import models +import type { + ApiError, + PaginatedGet200Response, + RegularGet200Response, + TestRequest, + TestResponse, +} from '../models'; +// Import request parameter interfaces +import { + AnyRequestResponseRequest, + MediaTypesRequest, + OperationOneRequest, + PaginatedGetRequest, +} from '..'; + +import { ResponseError } from '../runtime'; +import { DefaultApi } from './DefaultApi'; + +import { + InitialPageParam, + useQuery, + UseQueryResult, + UseQueryOptions, + useInfiniteQuery, + UseInfiniteQueryResult, + UseInfiniteQueryOptions, + useMutation, + UseMutationOptions, + UseMutationResult +} from "@tanstack/react-query"; +import { createContext, useContext } from "react"; + +/** + * Context for the API client used by the hooks. + */ +export const DefaultApiClientContext = createContext(undefined); + +const NO_API_ERROR = new Error(\`DefaultApi client missing. Please ensure you have instantiated the DefaultApiClientProvider with a client instance.\`); + +/** + * useMutation hook for the AnyRequestResponse operation + */ +export const useAnyRequestResponse = ( + options?: Omit, 'mutationFn'> +): UseMutationResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useMutation({ + mutationFn: (params: AnyRequestResponseRequest) => api.anyRequestResponse(params), + ...options, + }); +}; + +/** + * useMutation hook for the Empty operation + */ +export const useEmpty = ( + options?: Omit, 'mutationFn'> +): UseMutationResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useMutation({ + mutationFn: () => api.empty(), + ...options, + }); +}; + +/** + * useMutation hook for the MediaTypes operation + */ +export const useMediaTypes = ( + options?: Omit, 'mutationFn'> +): UseMutationResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useMutation({ + mutationFn: (params: MediaTypesRequest) => api.mediaTypes(params), + ...options, + }); +}; + +/** + * useMutation hook for the OperationOne operation + */ +export const useOperationOne = ( + options?: Omit, 'mutationFn'> +): UseMutationResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useMutation({ + mutationFn: (params: OperationOneRequest) => api.operationOne(params), + ...options, + }); +}; + +/** + * useInfiniteQuery hook for the PaginatedGet operation + */ +export const usePaginatedGet = ( + params: PaginatedGetRequest, + options?: Omit, 'queryKey' | 'queryFn' | 'getNextPageParam' | 'initialPageParam'> & ( + InitialPageParam + ) +): UseInfiniteQueryResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useInfiniteQuery({ + queryKey: ["paginatedGet", params], + queryFn: ({ pageParam }) => api.paginatedGet({ ...params, inputNextToken: pageParam as any }), + getNextPageParam: (response) => response.outputNextToken, + ...options, + }); +}; + +/** + * useQuery hook for the RegularGet operation + */ +export const useRegularGet = ( + options?: Omit, 'queryKey' | 'queryFn'> +): UseQueryResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useQuery({ + queryKey: ["regularGet"], + queryFn: () => api.regularGet(), + ...options, + }); +}; + +/** + * useMutation hook for the WithoutOperationIdDelete operation + */ +export const useWithoutOperationIdDelete = ( + options?: Omit, 'mutationFn'> +): UseMutationResult => { + const api = useContext(DefaultApiClientContext); + if (!api) { + throw NO_API_ERROR; + } + return useMutation({ + mutationFn: () => api.withoutOperationIdDelete(), + ...options, + }); +}; + +", + "src/apis/index.ts": "/* tslint:disable */ +/* eslint-disable */ +export * from './DefaultApi'; +export * from './DefaultApiHooks'; +export * from './DefaultApiClientProvider'; +", + "src/index.ts": "/* tslint:disable */ +/* eslint-disable */ +export * from './runtime'; +export * from './apis'; +export * from './models';", + "src/models/ApiError.ts": "/* tslint:disable */ +/* eslint-disable */ +/** + * Example API + * + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated. + * Do not edit the class manually. + */ +import { exists, mapValues } from './model-utils'; + +/** + * + * @export + * @interface ApiError + */ +export interface ApiError { + /** + * + * @type {string} + * @memberof ApiError + */ + errorMessage: string; +} + + +/** + * Check if a given object implements the ApiError interface. + */ +export function instanceOfApiError(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "errorMessage" in value; + return isInstance; +} + +export function ApiErrorFromJSON(json: any): ApiError { + return ApiErrorFromJSONTyped(json, false); +} + +export function ApiErrorFromJSONTyped(json: any, ignoreDiscriminator: boolean): ApiError { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'errorMessage': json['errorMessage'], + }; +} + +export function ApiErrorToJSON(value?: ApiError | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'errorMessage': value.errorMessage, + }; +} + +", + "src/models/PaginatedGet200Response.ts": "/* tslint:disable */ +/* eslint-disable */ +/** + * Example API + * + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated. + * Do not edit the class manually. + */ +import { exists, mapValues } from './model-utils'; + +/** + * + * @export + * @interface PaginatedGet200Response + */ +export interface PaginatedGet200Response { + /** + * + * @type {string} + * @memberof PaginatedGet200Response + */ + outputNextToken?: string; + /** + * + * @type {Array} + * @memberof PaginatedGet200Response + */ + results?: Array; +} + + +/** + * Check if a given object implements the PaginatedGet200Response interface. + */ +export function instanceOfPaginatedGet200Response(value: object): boolean { + let isInstance = true; + return isInstance; +} + +export function PaginatedGet200ResponseFromJSON(json: any): PaginatedGet200Response { + return PaginatedGet200ResponseFromJSONTyped(json, false); +} + +export function PaginatedGet200ResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): PaginatedGet200Response { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'outputNextToken': !exists(json, 'outputNextToken') ? undefined : json['outputNextToken'], + 'results': !exists(json, 'results') ? undefined : json['results'], + }; +} + +export function PaginatedGet200ResponseToJSON(value?: PaginatedGet200Response | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'outputNextToken': value.outputNextToken, + 'results': value.results === undefined ? undefined : value.results, + }; +} + +", + "src/models/RegularGet200Response.ts": "/* tslint:disable */ +/* eslint-disable */ +/** + * Example API + * + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated. + * Do not edit the class manually. + */ +import { exists, mapValues } from './model-utils'; + +/** + * + * @export + * @interface RegularGet200Response + */ +export interface RegularGet200Response { + /** + * + * @type {string} + * @memberof RegularGet200Response + */ + foo?: string; +} + + +/** + * Check if a given object implements the RegularGet200Response interface. + */ +export function instanceOfRegularGet200Response(value: object): boolean { + let isInstance = true; + return isInstance; +} + +export function RegularGet200ResponseFromJSON(json: any): RegularGet200Response { + return RegularGet200ResponseFromJSONTyped(json, false); +} + +export function RegularGet200ResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): RegularGet200Response { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'foo': !exists(json, 'foo') ? undefined : json['foo'], + }; +} + +export function RegularGet200ResponseToJSON(value?: RegularGet200Response | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'foo': value.foo, + }; +} + +", + "src/models/TestRequest.ts": "/* tslint:disable */ +/* eslint-disable */ +/** + * Example API + * + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated. + * Do not edit the class manually. + */ +import { exists, mapValues } from './model-utils'; + +/** + * + * @export + * @interface TestRequest + */ +export interface TestRequest { + /** + * + * @type {number} + * @memberof TestRequest + */ + myInput?: number; +} + + +/** + * Check if a given object implements the TestRequest interface. + */ +export function instanceOfTestRequest(value: object): boolean { + let isInstance = true; + return isInstance; +} + +export function TestRequestFromJSON(json: any): TestRequest { + return TestRequestFromJSONTyped(json, false); +} + +export function TestRequestFromJSONTyped(json: any, ignoreDiscriminator: boolean): TestRequest { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'myInput': !exists(json, 'myInput') ? undefined : json['myInput'], + }; +} + +export function TestRequestToJSON(value?: TestRequest | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'myInput': value.myInput, + }; +} + +", + "src/models/TestResponse.ts": "/* tslint:disable */ +/* eslint-disable */ +/** + * Example API + * + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated. + * Do not edit the class manually. + */ +import { exists, mapValues } from './model-utils'; +import type { TestResponseMessagesInner } from './TestResponseMessagesInner'; +import { + TestResponseMessagesInnerFromJSON, + TestResponseMessagesInnerFromJSONTyped, + TestResponseMessagesInnerToJSON, + instanceOfTestResponseMessagesInner, +} from './TestResponseMessagesInner'; + +/** + * + * @export + * @interface TestResponse + */ +export interface TestResponse { + /** + * + * @type {Array} + * @memberof TestResponse + */ + messages: Array; +} + + +/** + * Check if a given object implements the TestResponse interface. + */ +export function instanceOfTestResponse(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "messages" in value; + return isInstance; +} + +export function TestResponseFromJSON(json: any): TestResponse { + return TestResponseFromJSONTyped(json, false); +} + +export function TestResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): TestResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'messages': ((json['messages'] as Array).map(TestResponseMessagesInnerFromJSON)), + }; +} + +export function TestResponseToJSON(value?: TestResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'messages': ((value.messages as Array).map(TestResponseMessagesInnerToJSON)), + }; +} + +", + "src/models/TestResponseMessagesInner.ts": "/* tslint:disable */ +/* eslint-disable */ +/** + * Example API + * + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated. + * Do not edit the class manually. + */ +import { exists, mapValues } from './model-utils'; + +/** + * + * @export + * @interface TestResponseMessagesInner + */ +export interface TestResponseMessagesInner { + /** + * + * @type {string} + * @memberof TestResponseMessagesInner + */ + message?: string; + /** + * + * @type {number} + * @memberof TestResponseMessagesInner + */ + id: number; +} + + +/** + * Check if a given object implements the TestResponseMessagesInner interface. + */ +export function instanceOfTestResponseMessagesInner(value: object): boolean { + let isInstance = true; + isInstance = isInstance && "id" in value; + return isInstance; +} + +export function TestResponseMessagesInnerFromJSON(json: any): TestResponseMessagesInner { + return TestResponseMessagesInnerFromJSONTyped(json, false); +} + +export function TestResponseMessagesInnerFromJSONTyped(json: any, ignoreDiscriminator: boolean): TestResponseMessagesInner { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'message': !exists(json, 'message') ? undefined : json['message'], + 'id': json['id'], + }; +} + +export function TestResponseMessagesInnerToJSON(value?: TestResponseMessagesInner | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'message': value.message, + 'id': value.id, + }; +} + +", + "src/models/index.ts": "/* tslint:disable */ +/* eslint-disable */ +export * from './ApiError'; +export * from './PaginatedGet200Response'; +export * from './RegularGet200Response'; +export * from './TestRequest'; +export * from './TestResponse'; +export * from './TestResponseMessagesInner'; +", + "src/models/model-utils.ts": "/* tslint:disable */ +/* eslint-disable */ + +export function mapValues(data: any, fn: (item: any) => any) { + return Object.keys(data).reduce( + (acc, key) => ({ ...acc, [key]: fn(data[key]) }), + {} + ); +} + +export function exists(json: any, key: string) { + const value = json[key]; + return value !== null && value !== undefined; +} +", + "src/runtime.ts": "/* tslint:disable */ +/* eslint-disable */ +/** + * Example API + * + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated. + * Do not edit the class manually. + */ + +export const BASE_PATH = "http://localhost".replace(/\\/+$/, ""); + +export interface ConfigurationParameters { + basePath?: string; // override base path + fetchApi?: FetchAPI; // override for fetch implementation + middleware?: Middleware[]; // middleware to apply before/after fetch requests + queryParamsStringify?: (params: HTTPQuery) => string; // stringify function for query strings + username?: string; // parameter for basic security + password?: string; // parameter for basic security + apiKey?: string | ((name: string) => string); // parameter for apiKey security + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string | Promise); // parameter for oauth2 security + headers?: HTTPHeaders; //header params we want to use on every request + credentials?: RequestCredentials; //value for the credentials param we want to use on each request +} + +export class Configuration { + constructor(private configuration: ConfigurationParameters = {}) {} + + set config(configuration: Configuration) { + this.configuration = configuration; + } + + get basePath(): string { + return this.configuration.basePath != null ? this.configuration.basePath : BASE_PATH; + } + + get fetchApi(): FetchAPI | undefined { + return this.configuration.fetchApi; + } + + get middleware(): Middleware[] { + return this.configuration.middleware || []; + } + + get queryParamsStringify(): (params: HTTPQuery) => string { + return this.configuration.queryParamsStringify || querystring; + } + + get username(): string | undefined { + return this.configuration.username; + } + + get password(): string | undefined { + return this.configuration.password; + } + + get apiKey(): ((name: string) => string) | undefined { + const apiKey = this.configuration.apiKey; + if (apiKey) { + return typeof apiKey === 'function' ? apiKey : () => apiKey; + } + return undefined; + } + + get accessToken(): ((name?: string, scopes?: string[]) => string | Promise) | undefined { + const accessToken = this.configuration.accessToken; + if (accessToken) { + return typeof accessToken === 'function' ? accessToken : async () => accessToken; + } + return undefined; + } + + get headers(): HTTPHeaders | undefined { + return this.configuration.headers; + } + + get credentials(): RequestCredentials | undefined { + return this.configuration.credentials; + } +} + +export const DefaultConfig = new Configuration(); + +/** + * This is the base class for all generated API classes. + */ +export class BaseAPI { + + private middleware: Middleware[]; + + constructor(protected configuration = DefaultConfig) { + this.middleware = configuration.middleware; + } + + withMiddleware(this: T, ...middlewares: Middleware[]) { + const next = this.clone(); + next.middleware = next.middleware.concat(...middlewares); + return next; + } + + withPreMiddleware(this: T, ...preMiddlewares: Array) { + const middlewares = preMiddlewares.map((pre) => ({ pre })); + return this.withMiddleware(...middlewares); + } + + withPostMiddleware(this: T, ...postMiddlewares: Array) { + const middlewares = postMiddlewares.map((post) => ({ post })); + return this.withMiddleware(...middlewares); + } + + protected async request(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction): Promise { + const { url, init } = await this.createFetchParams(context, initOverrides); + const response = await this.fetchApi(url, init); + if (response && (response.status >= 200 && response.status < 300)) { + return response; + } + throw new ResponseError(response, 'Response returned an error code'); + } + + private async createFetchParams(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction) { + let url = this.configuration.basePath + context.path; + if (context.query !== undefined && Object.keys(context.query).length !== 0) { + // only add the querystring to the URL if there are query parameters. + // this is done to avoid urls ending with a "?" character which buggy webservers + // do not handle correctly sometimes. + url += '?' + this.configuration.queryParamsStringify(context.query); + } + + const headers = Object.assign({}, this.configuration.headers, context.headers); + Object.keys(headers).forEach(key => headers[key] === undefined ? delete headers[key] : {}); + + const initOverrideFn = + typeof initOverrides === "function" + ? initOverrides + : async () => initOverrides; + + const initParams = { + method: context.method, + headers, + body: context.body, + credentials: this.configuration.credentials, + }; + + const overriddenInit: RequestInit = { + ...initParams, + ...(await initOverrideFn({ + init: initParams, + context, + })) + }; + + const init: RequestInit = { + ...overriddenInit, + body: + isFormData(overriddenInit.body) || + overriddenInit.body instanceof URLSearchParams || + isBlob(overriddenInit.body) + ? overriddenInit.body + : JSON.stringify(overriddenInit.body), + }; + + return { url, init }; + } + + private fetchApi = async (url: string, init: RequestInit) => { + let fetchParams = { url, init }; + for (const middleware of this.middleware) { + if (middleware.pre) { + fetchParams = await middleware.pre({ + fetch: this.fetchApi, + ...fetchParams, + }) || fetchParams; + } + } + let response: Response | undefined = undefined; + try { + response = await (this.configuration.fetchApi || fetch)(fetchParams.url, fetchParams.init); + } catch (e) { + for (const middleware of this.middleware) { + if (middleware.onError) { + response = await middleware.onError({ + fetch: this.fetchApi, + url: fetchParams.url, + init: fetchParams.init, + error: e, + response: response ? response.clone() : undefined, + }) || response; + } + } + if (response === undefined) { + if (e instanceof Error) { + throw new FetchError(e, 'The request failed and the interceptors did not return an alternative response'); + } else { + throw e; + } + } + } + for (const middleware of this.middleware) { + if (middleware.post) { + response = await middleware.post({ + fetch: this.fetchApi, + url: fetchParams.url, + init: fetchParams.init, + response: response.clone(), + }) || response; + } + } + return response; + } + + /** + * Create a shallow clone of \`this\` by constructing a new instance + * and then shallow cloning data members. + */ + private clone(this: T): T { + const constructor = this.constructor as any; + const next = new constructor(this.configuration); + next.middleware = this.middleware.slice(); + return next; + } +}; + +function isBlob(value: any): value is Blob { + return typeof Blob !== 'undefined' && value instanceof Blob; +} + +function isFormData(value: any): value is FormData { + return typeof FormData !== "undefined" && value instanceof FormData; +} + +export class ResponseError extends Error { + override name: "ResponseError" = "ResponseError"; + constructor(public response: Response, msg?: string) { + super(msg); + } +} + +export class FetchError extends Error { + override name: "FetchError" = "FetchError"; + constructor(public cause: Error, msg?: string) { + super(msg); + } +} + +export class RequiredError extends Error { + override name: "RequiredError" = "RequiredError"; + constructor(public field: string, msg?: string) { + super(msg); + } +} + +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\\t", + pipes: "|", +}; + +export type FetchAPI = WindowOrWorkerGlobalScope['fetch']; + +export type Json = any; +export type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; +export type HTTPHeaders = { [key: string]: string }; +export type HTTPQuery = { [key: string]: string | number | null | boolean | Array | Set | HTTPQuery }; +export type HTTPBody = Json | FormData | URLSearchParams; +export type HTTPRequestInit = { headers?: HTTPHeaders; method: HTTPMethod; credentials?: RequestCredentials; body?: HTTPBody }; +export type ModelPropertyNaming = 'camelCase' | 'snake_case' | 'PascalCase' | 'original'; + +export type InitOverrideFunction = (requestContext: { init: HTTPRequestInit, context: RequestOpts }) => Promise + +export interface FetchParams { + url: string; + init: RequestInit; +} + +export interface RequestOpts { + path: string; + method: HTTPMethod; + headers: HTTPHeaders; + query?: HTTPQuery; + body?: HTTPBody; +} + +export function exists(json: any, key: string) { + const value = json[key]; + return value !== null && value !== undefined; +} + +export function querystring(params: HTTPQuery, prefix: string = ''): string { + return Object.keys(params) + .map(key => querystringSingleKey(key, params[key], prefix)) + .filter(part => part.length > 0) + .join('&'); +} + +function querystringSingleKey(key: string, value: string | number | null | undefined | boolean | Array | Set | HTTPQuery, keyPrefix: string = ''): string { + const fullKey = keyPrefix + (keyPrefix.length ? \`[\${key}]\` : key); + if (value instanceof Array) { + const multiValue = value.map(singleValue => encodeURIComponent(String(singleValue))) + .join(\`&\${encodeURIComponent(fullKey)}=\`); + return \`\${encodeURIComponent(fullKey)}=\${multiValue}\`; + } + if (value instanceof Set) { + const valueAsArray = Array.from(value); + return querystringSingleKey(key, valueAsArray, keyPrefix); + } + if (value instanceof Date) { + return \`\${encodeURIComponent(fullKey)}=\${encodeURIComponent(value.toISOString())}\`; + } + if (value instanceof Object) { + return querystring(value as HTTPQuery, fullKey); + } + return \`\${encodeURIComponent(fullKey)}=\${encodeURIComponent(String(value))}\`; +} + +export function mapValues(data: any, fn: (item: any) => any) { + return Object.keys(data).reduce( + (acc, key) => ({ ...acc, [key]: fn(data[key]) }), + {} + ); +} + +export function canConsumeForm(consumes: Consume[]): boolean { + for (const consume of consumes) { + if ('multipart/form-data' === consume.contentType) { + return true; + } + } + return false; +} + +export interface Consume { + contentType: string; +} + +export interface RequestContext { + fetch: FetchAPI; + url: string; + init: RequestInit; +} + +export interface ResponseContext { + fetch: FetchAPI; + url: string; + init: RequestInit; + response: Response; +} + +export interface ErrorContext { + fetch: FetchAPI; + url: string; + init: RequestInit; + error: unknown; + response?: Response; +} + +export interface Middleware { + pre?(context: RequestContext): Promise; + post?(context: ResponseContext): Promise; + onError?(context: ErrorContext): Promise; +} + +export interface ApiResponse { + raw: Response; + value(): Promise; +} + +export interface ResponseTransformer { + (json: any): T; +} + +export class JSONApiResponse { + constructor(public raw: Response, private transformer: ResponseTransformer = (jsonValue: any) => jsonValue) {} + + async value(): Promise { + return this.transformer(await this.raw.json()); + } +} + +export class VoidApiResponse { + constructor(public raw: Response) {} + + async value(): Promise { + return undefined; + } +} + +export class BlobApiResponse { + constructor(public raw: Response) {} + + async value(): Promise { + return await this.raw.blob(); + }; +} + +export class TextApiResponse { + constructor(public raw: Response) {} + + async value(): Promise { + return await this.raw.text(); + }; +} +", +} +`; diff --git a/packages/type-safe-api/test/scripts/generators/typescript-esm.test.ts b/packages/type-safe-api/test/scripts/generators/typescript-esm.test.ts index 4ffffa940..ead30269e 100644 --- a/packages/type-safe-api/test/scripts/generators/typescript-esm.test.ts +++ b/packages/type-safe-api/test/scripts/generators/typescript-esm.test.ts @@ -14,6 +14,8 @@ describe("TypeScript ESM Generator Tests", () => { ["typescript-lambda-handlers", "handlers.yaml"], ["typescript-async-lambda-handlers", "async/handlers.yaml"], ["typescript-websocket-client", "async/handlers.yaml"], + ["typescript-react-query-hooks", "handlers.yaml"], + ["typescript-websocket-hooks", "async/handlers.yaml"], ])("Generates %s with ESM compatible syntax", (templateDir, spec) => { const specPath = path.resolve(__dirname, `../../resources/specs/${spec}`); expect( diff --git a/packages/type-safe-api/test/scripts/generators/typescript-react-query-hooks.test.ts b/packages/type-safe-api/test/scripts/generators/typescript-react-query-hooks.test.ts index e9db0ed4f..af6d9cac5 100644 --- a/packages/type-safe-api/test/scripts/generators/typescript-react-query-hooks.test.ts +++ b/packages/type-safe-api/test/scripts/generators/typescript-react-query-hooks.test.ts @@ -42,4 +42,40 @@ describe("Typescript React Query Hooks Code Generation Script Unit Tests", () => ).toMatchSnapshot(); } ); + + it("Generates react query v5 hooks", () => { + const specPath = path.resolve( + __dirname, + `../../resources/specs/single-pagination.yaml` + ); + expect( + withTmpDirSnapshot( + os.tmpdir(), + (outdir) => { + exec(`cp ${specPath} ${outdir}/spec.yaml`, { + cwd: path.resolve(__dirname), + }); + const project = new TypescriptReactQueryHooksLibrary({ + name: "test", + defaultReleaseBranch: "main", + outdir, + specPath: "spec.yaml", + useReactQueryV5: true, + }); + exec( + `${path.resolve( + __dirname, + "../../../scripts/type-safe-api/run.js generate" + )} ${project.buildGenerateCommandArgs()}`, + { + cwd: outdir, + } + ); + }, + { + excludeGlobs: [".projen/*", "spec.yaml"], + } + ) + ).toMatchSnapshot(); + }); });