diff --git a/.gitignore b/.gitignore index b0a76c1..534545f 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dist .pnp.* lib/ + +# AI Assistant Configuration +CLAUDE.md diff --git a/.prettierignore b/.prettierignore index e738f12..56eb95b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,5 @@ coverage node_modules lib + +README.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d62cb81 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- New query options exports for better React Query v5 compatibility + - `{methodName}QueryOptions` functions for regular queries + - `{methodName}MutationOptions` functions for mutations + - `{methodName}InfiniteQueryOptions` functions for infinite queries +- Service getter functions (`get{ServiceName}Service`) for use in non-React contexts +- Query key builder utility for type-safe cache invalidation and queries + +### Changed + +- Generated hooks now use simplified `@deprecated` JSDoc tags instead of custom deprecation blocks +- Query keys now use a simpler static structure based on interface and method names + - Changed from URL-based resource keys to pattern: `['interface', 'method', params || {}]` + - Interface names in query keys now use camelCase for consistency with JavaScript conventions + - Removed complex URL path parsing logic for cleaner, more predictable keys +- Refactored internal code generation to use helper functions instead of NameFactory class + +### Fixed + +- Parameter names with special characters (e.g., hyphens) are now properly handled in query keys + - All parameter access now uses bracket notation for consistency + - Object keys in query key generation are properly quoted +- Fixed duplicate function declarations for methods not starting with "get" + - Suspense hooks now correctly generate with `useSuspense` prefix for all method types + - Prevents TypeScript errors from duplicate function names +- Fixed invalid TypeScript syntax in query keys where optional parameter syntax (`params?`) was incorrectly used in runtime expressions +- Fixed infinite query key typo (`inifinite` → `infinite`) +- Build configuration now properly excludes snapshot directory from TypeScript compilation +- Added README.md to .prettierignore to prevent formatter hanging + +### Deprecated + +- Legacy hook exports (`use{MethodName}`, `useSuspense{MethodName}`, etc.) are now deprecated + - These hooks will be removed in a future major version + - Users should migrate to the new query options pattern with React Query's built-in hooks diff --git a/README.md b/README.md index d16892a..1198188 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,173 @@ # React Query -[Basketry generator](https://github.com/basketry/basketry) for generating React Query hooks. This parser can be coupled with any Basketry parser. +[Basketry generator](https://basketry.io) for generating React Query queryOptions and hooks. This generator can be coupled with any Basketry parser. ## Quick Start -// TODO +### Installation + +```bash +npm install @basketry/react-query +``` + +### Getting Started + +1. **Create a Basketry configuration file** (`basketry.config.json`): + ```json + { + "source": "openapi.json", + "parser": "@basketry/openapi-3", + "generators": ["@basketry/react-query"], + "output": "./src/generated/react-query", + "options": { + "basketry": { + "command": "npx basketry" + }, + "typescript": { + "includeVersion": false + }, + "reactQuery": { + "typesModule": "@your-api/types", // Path to generated TypeScript types + "clientModule": "@your-api/http-client-sdk" // Path to generated HTTP client + } + } + } + ``` + +2. **Run Basketry** to generate the React Query hooks: + ```bash + npx basketry + ``` + +3. **Set up your React Query provider** in your app: + ```typescript + import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + // Name of provider will depend on the name of the API service in your OpenAPI spec. + import { BasketryExampleProvider } from './src/generated/context'; + + const queryClient = new QueryClient(); + const httpClient = fetch; // or your custom fetch implementation + + function App() { + return ( + + + {/* Your app components */} + + + ); + } + ``` + +4. **Use the generated hooks** in your components: + ```typescript + import { useQuery } from '@tanstack/react-query'; + import { getWidgetsQueryOptions } from './src/generated'; + + function WidgetList() { + const { data, isLoading } = useQuery(getWidgetsQueryOptions()); + + if (isLoading) return
Loading...
; + return
{data?.map(widget =>
{widget.name}
)}
; + } + ``` + +### Basic Usage + +This generator produces React Query compatible code with queryOptions functions that provide maximum flexibility: + +```typescript +// Using query options with React Query hooks +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { getWidgetsQueryOptions } from './petstore'; // generated code + +function WidgetList() { + // Basic usage + const { data } = useQuery(getWidgetsQueryOptions()); + + // With parameters + const { data: filtered } = useQuery( + getWidgetsQueryOptions({ status: 'active' }) + ); + + // With custom options + const { data: cached } = useQuery({ + ...getWidgetsQueryOptions(), + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + return
{/* render widgets */}
; +} +``` + +### Mutations + +```typescript +import { useMutation } from '@tanstack/react-query'; +import { createWidgetMutationOptions } from './petstore'; // generated code + +function CreateWidget() { + const mutation = useMutation(createWidgetMutationOptions()); + + const handleSubmit = (data: CreateWidgetInput) => { + mutation.mutate(data, { + onSuccess: (widget) => { + console.log('Created widget:', widget); + }, + }); + }; + + return
{/* form fields */}
; +} +``` + +### Infinite Queries (Pagination) + +For services with Relay-style pagination: + +```typescript +import { useInfiniteQuery } from '@tanstack/react-query'; +import { getWidgetsInfiniteQueryOptions } from './petstore'; // generated code + +function InfiniteWidgetList() { + const { + data, + fetchNextPage, + hasNextPage, + } = useInfiniteQuery(getWidgetsInfiniteQueryOptions()); + + return ( +
+ {data?.pages.map(page => + page.edges.map(({ node }) => ( + + )) + )} + +
+ ); +} +``` + +## Configuration + +Add to your `basketry.config.json`: + +```json +``` + +## Features + +- **React Query Compatible**: Generates queryOptions and mutationOptions functions +- **Type-Safe**: Full TypeScript support with proper type inference +- **Flexible**: Use with any React Query hook (useQuery, useSuspenseQuery, etc.) +- **SSR Ready**: Service getters work outside React components +- **Backward Compatible**: Legacy hooks are deprecated but still available +- **Relay Pagination**: Built-in support for cursor-based pagination +- **Error Handling**: Automatic error aggregation with CompositeError --- @@ -24,7 +186,7 @@ Note that the `lint` script is run prior to `build`. Auto-fixable linting or for ### Create and run tests 1. Add tests by creating files with the `.test.ts` suffix -1. Run the tests: `npm t` +1. Run the tests: `npm test` 1. Test coverage can be viewed at `/coverage/lcov-report/index.html` ### Publish a new package version diff --git a/package-lock.json b/package-lock.json index a6e008a..5671f22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@basketry/react-query", - "version": "0.0.0", + "version": "0.2.0-alpha.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@basketry/react-query", - "version": "0.0.0", + "version": "0.2.0-alpha.3", "license": "MIT", "dependencies": { "@basketry/typescript": "^0.1.2", diff --git a/package.json b/package.json index c8e904c..625192b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@basketry/react-query", - "version": "0.0.0", - "description": "Basketry generator for generating Typescript interfaces", + "version": "0.2.0-alpha.3", + "description": "Basketry generator for generating React Query hooks", "main": "./lib/index.js", "scripts": { "test": "jest", diff --git a/src/context-file.ts b/src/context-file.ts index 6fb1b8a..6af4bd9 100644 --- a/src/context-file.ts +++ b/src/context-file.ts @@ -1,6 +1,13 @@ import { camel, pascal } from 'case'; import { ModuleBuilder } from './module-builder'; import { ImportBuilder } from './import-builder'; +import { + buildContextName, + buildProviderName, + buildServiceHookName, + buildServiceGetterName, + buildServiceName, +} from './name-helpers'; export class ContextFile extends ModuleBuilder { private readonly react = new ImportBuilder('react'); @@ -23,23 +30,49 @@ export class ContextFile extends ModuleBuilder { const FetchLike = () => this.client.type('FetchLike'); const OptionsType = () => this.client.type(optionsName); - yield `export interface ClientContextProps { fetch: ${FetchLike()}; options: ${OptionsType()}; }`; - yield `const ClientContext = ${createContext()}( undefined );`; + // Use consistent naming from helper functions + const contextName = buildContextName(this.service); + const contextPropsName = pascal(`${contextName}_props`); + const providerName = buildProviderName(this.service); + + yield `export interface ${contextPropsName} { fetch: ${FetchLike()}; options: ${OptionsType()}; }`; + yield `const ${contextName} = ${createContext()}<${contextPropsName} | undefined>( undefined );`; + yield ``; + + // Store context for non-hook access + yield `let currentContext: ${contextPropsName} | undefined;`; yield ``; - yield `export const ClientProvider: ${FC()}<${PropsWithChildren()}> = ({ children, fetch, options }) => {`; + + yield `export const ${providerName}: ${FC()}<${PropsWithChildren()}<${contextPropsName}>> = ({ children, fetch, options }) => {`; yield ` const value = ${useMemo()}(() => ({ fetch, options }), [fetch, options.mapUnhandledException, options.mapValidationError, options.root]);`; - yield ` return {children};`; + yield ` currentContext = value;`; + yield ` return <${contextName}.Provider value={value}>{children};`; yield `};`; + for (const int of this.service.interfaces) { - const hookName = camel(`use_${int.name.value}_service`); - const localName = camel(`${int.name.value}_service`); + const hookName = buildServiceHookName(int); + const getterName = buildServiceGetterName(int); + const localName = buildServiceName(int); const interfaceName = pascal(`${int.name.value}_service`); const className = pascal(`http_${int.name.value}_service`); + // Add service getter function (v0.2.0) + yield ``; + yield `export const ${getterName} = () => {`; + yield ` if (!currentContext) { throw new Error('${getterName} called outside of ${providerName}'); }`; + yield ` const ${localName}: ${this.types.type( + interfaceName, + )} = new ${this.client.fn( + className, + )}(currentContext.fetch, currentContext.options);`; + yield ` return ${localName};`; + yield `};`; + + // Keep legacy hook for backward compatibility (v0.1.0) yield ``; yield `export const ${hookName} = () => {`; - yield ` const context = ${useContext()}(ClientContext);`; - yield ` if (!context) { throw new Error('${hookName} must be used within a ClientProvider'); }`; + yield ` const context = ${useContext()}(${contextName});`; + yield ` if (!context) { throw new Error('${hookName} must be used within a ${providerName}'); }`; yield ` const ${localName}: ${this.types.type( interfaceName, )} = new ${this.client.fn(className)}(context.fetch, context.options);`; diff --git a/src/hook-file.ts b/src/hook-file.ts index ad199a3..af0f3a3 100644 --- a/src/hook-file.ts +++ b/src/hook-file.ts @@ -17,11 +17,18 @@ import { } from '@basketry/typescript'; import { from } from '@basketry/typescript/lib/utils'; -import { camel } from 'case'; +import { camel, pascal } from 'case'; import { NamespacedReactQueryOptions } from './types'; import { ModuleBuilder } from './module-builder'; import { ImportBuilder } from './import-builder'; -import { getQueryOptionsName } from './name-factory'; +import { + getQueryOptionsName, + buildServiceGetterName, + buildQueryOptionsName, + buildMutationOptionsName, + buildInfiniteQueryOptionsName, + buildServiceHookName, +} from './name-helpers'; export class HookFile extends ModuleBuilder { constructor( @@ -46,6 +53,7 @@ export class HookFile extends ModuleBuilder { ]; *body(): Iterable { + // === LEGACY HOOKS (v0.1.0) === const useMutation = () => this.tanstack.fn('useMutation'); const useQuery = () => this.tanstack.fn('useQuery'); const useQueryClient = () => this.tanstack.fn('useQueryClient'); @@ -74,7 +82,6 @@ export class HookFile extends ModuleBuilder { )) { const name = this.getHookName(method); const suspenseName = this.getHookName(method, { suspense: true }); - const infiniteName = this.getHookName(method, { infinite: true }); const paramsType = from(buildParamsType(method)); const httpMethod = getHttpMethodByName(this.service, method.name.value); const httpPath = this.getHttpPath(httpMethod); @@ -88,10 +95,6 @@ export class HookFile extends ModuleBuilder { const isGet = httpMethod?.verb.value === 'get' && !!httpPath; - if (isGet) { - yield* this.generateQueryOptions(method, httpPath); - } - if (isGet) { const queryOptionsName = getQueryOptionsName(method); const paramsCallsite = method.parameters.length ? 'params' : ''; @@ -134,11 +137,7 @@ export class HookFile extends ModuleBuilder { dataTypeName, )} | undefined, (${queryParamsType})[]>,'queryKey' | 'queryFn' | 'select'>`; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, - ); + yield* buildDescription(method.description, undefined, true); yield `export function ${name}(${[ paramsExpression, optionsExpression, @@ -147,11 +146,7 @@ export class HookFile extends ModuleBuilder { yield ` return ${useQuery()}({...defaultOptions, ...options});`; yield `}`; yield ''; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, - ); + yield* buildDescription(method.description, undefined, true); yield `export function ${suspenseName}(${[ paramsExpression, optionsExpression, @@ -178,11 +173,7 @@ export class HookFile extends ModuleBuilder { typeName, )}, Error, ${type(paramsType)}, unknown>, 'mutationFn'>`; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, - ); + yield* buildDescription(method.description, undefined, true); yield `export function ${name}(${optionsExpression}) {`; yield ` const queryClient = ${useQueryClient()}();`; yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}()`; @@ -194,15 +185,9 @@ export class HookFile extends ModuleBuilder { yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`; yield ` else if (!res.data) { throw new Error('Unexpected data error: Failed to get example'); }`; - const queryKeys = new Set(); - queryKeys.add(this.buildResourceKey(httpPath, method)); // Invalidate this resource - queryKeys.add( - this.buildResourceKey(httpPath, method, { skipTerminalParams: true }), // Invalidate the parent resource group - ); - - for (const queryKey of Array.from(queryKeys)) { - yield ` queryClient.invalidateQueries({ queryKey: [${queryKey}] });`; - } + // Invalidate all queries for this interface + const interfaceName = camel(this.int.name.value); + yield ` queryClient.invalidateQueries({ queryKey: ['${interfaceName}'] });`; yield ` return res.data;`; yield ` },`; yield ` ...options,`; @@ -223,7 +208,7 @@ export class HookFile extends ModuleBuilder { yield `function ${infiniteOptionsHook}(${paramsExpression}) {`; yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}();`; yield ` return {`; - yield ` queryKey: ${this.buildQueryKey(httpPath, method, { + yield ` queryKey: ${this.buildQueryKey(method, { includeRelayParams: false, infinite: true, })},`; @@ -241,11 +226,7 @@ export class HookFile extends ModuleBuilder { yield ` };`; yield `}`; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, - ); + yield* buildDescription(method.description, undefined, true); yield `export const ${this.getHookName(method, { suspense: false, infinite: true, @@ -254,11 +235,7 @@ export class HookFile extends ModuleBuilder { yield ` return ${useInfiniteQuery()}(options);`; yield `}`; - yield* buildDescription( - method.description, - undefined, - method.deprecated?.value, - ); + yield* buildDescription(method.description, undefined, true); yield `export const ${this.getHookName(method, { suspense: true, infinite: true, @@ -270,6 +247,35 @@ export class HookFile extends ModuleBuilder { yield ''; } + yield ''; + + // === NEW QUERY OPTIONS EXPORTS (v0.2.0) === + yield ''; + for (const method of this.int.methods) { + const httpMethod = getHttpMethodByName(this.service, method.name.value); + const httpPath = this.getHttpPath(httpMethod); + yield* this.generateAllQueryOptions(method, httpMethod, httpPath); + } + } + + private *generateAllQueryOptions( + method: Method, + httpMethod: HttpMethod | undefined, + httpPath: HttpPath | undefined, + ): Iterable { + if (!httpPath) return; + + const isGet = httpMethod?.verb.value === 'get'; + + if (isGet) { + yield* this.generateQueryOptions(method, httpPath); + + if (this.isRelayPaginated(method)) { + yield* this.generateInfiniteQueryOptions(method, httpPath); + } + } else { + yield* this.generateMutationOptions(method); + } } private *buildInfiniteSelectFn(method: Method): Iterable { @@ -294,6 +300,109 @@ export class HookFile extends ModuleBuilder { }),`; } + private *generateMutationOptions(method: Method): Iterable { + const mutationOptions = () => this.tanstack.fn('mutationOptions'); + const CompositeError = () => this.runtime.fn('CompositeError'); + const type = (t: string) => this.types.type(t); + + const serviceName = camel(`${this.int.name.value}_service`); + const serviceGetterName = buildServiceGetterName(this.int); + const mutationOptionsName = buildMutationOptionsName(method); + + const paramsType = from(buildParamsType(method)); + const paramsExpression = method.parameters.length + ? `params: ${type(paramsType)}` + : ''; + const paramsCallsite = method.parameters.length ? 'params' : ''; + + const returnType = getTypeByName( + this.service, + method.returnType?.typeName.value, + ); + const dataType = getTypeByName( + this.service, + returnType?.properties.find((p) => p.name.value === 'data')?.typeName + .value, + ); + + const typeName = dataType ? buildTypeName(dataType) : 'void'; + + yield* buildDescription( + method.description, + undefined, + method.deprecated?.value, + ); + yield `export const ${mutationOptionsName} = () => {`; + yield ` const ${serviceName} = ${this.context.fn(serviceGetterName)}()`; + yield ` return ${mutationOptions()}({`; + yield ` mutationFn: async (${paramsExpression}) => {`; + yield ` const res = await ${serviceName}.${camel( + method.name.value, + )}(${paramsCallsite});`; + yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`; + yield ` else if (!res.data) { throw new Error('Unexpected data error: Failed to get example'); }`; + yield ` return res.data;`; + yield ` },`; + yield ` });`; + yield `};`; + } + + private *generateInfiniteQueryOptions( + method: Method, + httpPath: HttpPath, + ): Iterable { + const infiniteQueryOptions = () => this.tanstack.fn('infiniteQueryOptions'); + const CompositeError = () => this.runtime.fn('CompositeError'); + const type = (t: string) => this.types.type(t); + const applyPageParam = () => this.runtime.fn('applyPageParam'); + const getInitialPageParam = () => this.runtime.fn('getInitialPageParam'); + const getNextPageParam = () => this.runtime.fn('getNextPageParam'); + const getPreviousPageParam = () => this.runtime.fn('getPreviousPageParam'); + const PageParam = () => this.runtime.type('PageParam'); + + const serviceName = camel(`${this.int.name.value}_service`); + const serviceGetterName = buildServiceGetterName(this.int); + const infiniteOptionsName = buildInfiniteQueryOptionsName(method); + + const paramsType = from(buildParamsType(method)); + const q = method.parameters.every((param) => !isRequired(param)) ? '?' : ''; + const paramsExpression = method.parameters.length + ? `params${q}: ${type(paramsType)}` + : ''; + + const methodExpression = `${serviceName}.${camel(method.name.value)}`; + const paramsCallsite = method.parameters.length + ? `${applyPageParam()}(params${q ? '?? {}' : ''}, pageParam)` + : ''; + + yield ''; + yield* buildDescription( + method.description, + undefined, + method.deprecated?.value, + ); + yield `export const ${infiniteOptionsName} = (${paramsExpression}) => {`; + yield ` const ${serviceName} = ${this.context.fn(serviceGetterName)}();`; + yield ` return ${infiniteQueryOptions()}({`; + yield ` queryKey: ${this.buildQueryKey(method, { + includeRelayParams: false, + infinite: true, + })},`; + yield ` queryFn: async ({ pageParam }: ${PageParam()}) => {`; + yield ` const res = await ${methodExpression}(${paramsCallsite});`; + yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`; + yield ` return res;`; + yield ` },`; + yield* this.buildInfiniteSelectFn(method); + yield ` initialPageParam: ${getInitialPageParam()}(params${ + q ? '?? {}' : '' + }),`; + yield ` ${getNextPageParam()},`; + yield ` ${getPreviousPageParam()},`; + yield ` });`; + yield `};`; + } + private *generateQueryOptions( method: Method, httpPath: HttpPath, @@ -303,8 +412,13 @@ export class HookFile extends ModuleBuilder { const type = (t: string) => this.types.type(t); const serviceName = camel(`${this.int.name.value}_service`); - const serviceHookName = camel(`use_${this.int.name.value}_service`); - const name = getQueryOptionsName(method); + const serviceGetterName = buildServiceGetterName(this.int); + + // Keep the internal function for backward compatibility + const internalName = getQueryOptionsName(method); + // New exported function name + const exportedName = buildQueryOptionsName(method); + const paramsType = from(buildParamsType(method)); const q = method.parameters.every((param) => !isRequired(param)) ? '?' : ''; const paramsExpression = method.parameters.length @@ -323,10 +437,40 @@ export class HookFile extends ModuleBuilder { (prop) => prop.name.value !== 'data' && prop.name.value !== 'errors', ); - yield `const ${name} = (${paramsExpression}) => {`; - yield ` const ${serviceName} = ${this.context.fn(serviceHookName)}()`; + // Internal function for backward compatibility with hooks + yield `const ${internalName} = (${paramsExpression}) => {`; + yield ` const ${serviceName} = ${this.context.fn( + buildServiceHookName(this.int), + )}()`; + yield ` return ${queryOptions()}({`; + yield ` queryKey: ${this.buildQueryKey(method, { + includeRelayParams: true, + })},`; + yield ` queryFn: async () => {`; + yield ` const res = await ${serviceName}.${camel( + method.name.value, + )}(${paramsCallsite});`; + yield ` if (res.errors.length) { throw new ${CompositeError()}(res.errors); }`; + yield ` else if (!res.data) { throw new Error('Unexpected data error: Failed to get example'); }`; + yield ` return res;`; + yield ` },`; + if (!skipSelect) { + yield ` select: (data) => data.data,`; + } + yield ` });`; + yield `};`; + + // New exported query options function (v0.2.0) + yield ''; + yield* buildDescription( + method.description, + undefined, + method.deprecated?.value, + ); + yield `export const ${exportedName} = (${paramsExpression}) => {`; + yield ` const ${serviceName} = ${this.context.fn(serviceGetterName)}()`; yield ` return ${queryOptions()}({`; - yield ` queryKey: ${this.buildQueryKey(httpPath, method, { + yield ` queryKey: ${this.buildQueryKey(method, { includeRelayParams: true, })},`; yield ` queryFn: async () => {`; @@ -362,7 +506,11 @@ export class HookFile extends ModuleBuilder { ); } - return camel(`use_${name}`); + return camel( + `use_${options?.suspense ? 'suspense_' : ''}${ + options?.infinite ? 'infinite_' : '' + }${name}`, + ); } private getHttpPath( @@ -384,71 +532,25 @@ export class HookFile extends ModuleBuilder { } private buildQueryKey( - httpPath: HttpPath, method: Method, options?: { includeRelayParams?: boolean; infinite?: boolean }, ): string { - const compact = () => this.runtime.fn('compact'); - - const resourceKey = this.buildResourceKey(httpPath, method); - const q = method.parameters.every((param) => !isRequired(param)) ? '?' : ''; - - const httpMethod = getHttpMethodByName(this.service, method.name.value); - const queryParams = httpMethod?.parameters.filter((p) => - isCacheParam(p, options?.includeRelayParams ?? false), - ); + const interfaceName = camel(this.int.name.value); + const methodName = camel(method.name.value); - const queryKey = [resourceKey]; + const queryKey = [`'${interfaceName}'`, `'${methodName}'`]; - let couldHaveNullQueryParams = false; - if (queryParams?.length) { - couldHaveNullQueryParams = queryParams.every((hp) => { - const param = method.parameters.find( - (p) => camel(p.name.value) === camel(hp.name.value), - ); - return param ? !isRequired(param) : true; - }); - queryKey.push( - `${compact()}({${queryParams - .map((p) => `${p.name.value}: params${q}.${p.name.value}`) - .join(',')}})`, - ); + if (method.parameters.length) { + queryKey.push(`params || {}`); + } else { + queryKey.push('{}'); } if (options?.infinite) { - queryKey.push('{inifinite: true}'); - } - - return `[${queryKey.join(', ')}]${ - couldHaveNullQueryParams ? '.filter(Boolean)' : '' - }`; - } - - private buildResourceKey( - httpPath: HttpPath, - method: Method, - options?: { skipTerminalParams: boolean }, - ): string { - const q = method.parameters.every((param) => !isRequired(param)) ? '?' : ''; - - const parts = httpPath.path.value.split('/'); - - if (options?.skipTerminalParams) { - while (isPathParam(parts[parts.length - 1])) { - parts.pop(); - } + queryKey.push('{infinite: true}'); } - const path = parts.filter(Boolean).map((part) => { - if (part.startsWith('{') && part.endsWith('}')) { - const param = part.slice(1, -1); - return `\${params${q}.${camel(param)}}`; - } - - return part; - }); - - return `\`/${path.join('/')}\``; + return `[${queryKey.join(', ')}]`; } private isRelayPaginated(method: Method): boolean { diff --git a/src/hook-generator.test.ts b/src/hook-generator.test.ts index 1c518bb..9e0bf31 100644 --- a/src/hook-generator.test.ts +++ b/src/hook-generator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { generateFiles } from './snapshot/test-utils'; -describe.skip('HookGenerator', () => { +describe('HookGenerator', () => { it('recreates a valid snapshot using the Engine', async () => { for await (const file of generateFiles()) { const snapshot = readFileSync(join(...file.path)).toString(); diff --git a/src/hook-generator.ts b/src/hook-generator.ts index 189ba58..70059d8 100644 --- a/src/hook-generator.ts +++ b/src/hook-generator.ts @@ -1,15 +1,13 @@ import { File, Generator, Service } from 'basketry'; import { plural } from 'pluralize'; - import { buildFilePath } from '@basketry/typescript'; import { format, from } from '@basketry/typescript/lib/utils'; -import { header } from '@basketry/typescript/lib/warning'; - import { kebab } from 'case'; import { NamespacedReactQueryOptions } from './types'; import { HookFile } from './hook-file'; import { ContextFile } from './context-file'; import { RuntimeFile } from './runtime-file'; +import { QueryKeyBuilder } from './query-key-builder'; export const generateHooks: Generator = (service, options) => { return new HookGenerator(service, options).generate(); @@ -40,6 +38,18 @@ class HookGenerator { ), }); + files.push({ + path: buildFilePath( + ['hooks', 'query-key-builder.ts'], + this.service, + this.options, + ), + contents: format( + from(new QueryKeyBuilder(this.service, this.options).build()), + this.options, + ), + }); + for (const int of this.service.interfaces) { const contents = format( from(new HookFile(this.service, this.options, int).build()), diff --git a/src/name-factory.ts b/src/name-factory.ts deleted file mode 100644 index 465525d..0000000 --- a/src/name-factory.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Method } from 'basketry'; -import { camel } from 'case'; - -export function getQueryOptionsName(method: Method): string { - return camel(`use_${method.name.value}_query_options`); -} diff --git a/src/name-helpers.ts b/src/name-helpers.ts new file mode 100644 index 0000000..40900a4 --- /dev/null +++ b/src/name-helpers.ts @@ -0,0 +1,38 @@ +import { Interface, Method, Service } from 'basketry'; +import { camel, pascal } from 'case'; + +export function getQueryOptionsName(method: Method): string { + return camel(`use_${method.name.value}_query_options`); +} + +export function buildServiceGetterName(int: Interface): string { + return camel(`get_${int.name.value}_service`); +} + +export function buildQueryOptionsName(method: Method): string { + return camel(`${method.name.value}_query_options`); +} + +export function buildMutationOptionsName(method: Method): string { + return camel(`${method.name.value}_mutation_options`); +} + +export function buildInfiniteQueryOptionsName(method: Method): string { + return camel(`${method.name.value}_infinite_query_options`); +} + +export function buildServiceHookName(int: Interface): string { + return camel(`use_${int.name.value}_service`); +} + +export function buildContextName(service: Service): string { + return pascal(`${service.title.value}_context`); +} + +export function buildProviderName(service: Service): string { + return pascal(`${service.title.value}_provider`); +} + +export function buildServiceName(int: Interface): string { + return camel(`${int.name.value}_service`); +} diff --git a/src/query-key-builder.ts b/src/query-key-builder.ts new file mode 100644 index 0000000..0c2bb5f --- /dev/null +++ b/src/query-key-builder.ts @@ -0,0 +1,165 @@ +import { isRequired, Method, Service } from 'basketry'; + +import { buildParamsType } from '@basketry/typescript'; +import { from } from '@basketry/typescript/lib/utils'; + +import { camel } from 'case'; +import { NamespacedReactQueryOptions } from './types'; +import { ModuleBuilder } from './module-builder'; +import { ImportBuilder } from './import-builder'; + +export class QueryKeyBuilder extends ModuleBuilder { + constructor( + service: Service, + options: NamespacedReactQueryOptions | undefined, + ) { + super(service, options); + } + + private readonly types = new ImportBuilder( + this.options?.reactQuery?.typesModule ?? '../types', + ); + + protected readonly importBuilders = [this.types]; + + *body(): Iterable { + // Generate QueryKeyMap interface + yield* this.generateQueryKeyMap(); + yield ''; + + // Generate type extraction helpers + yield* this.generateTypeHelpers(); + yield ''; + + // Generate matchQueryKey function + yield* this.generateMatchQueryKeyFunction(); + } + + private *generateQueryKeyMap(): Iterable { + yield '/**'; + yield ' * Type mapping for all available query keys in the service'; + yield ' */'; + yield 'export interface QueryKeyMap {'; + + for (const int of this.service.interfaces) { + const serviceName = camel(int.name.value); + yield ` ${serviceName}: {`; + + for (const method of int.methods) { + const methodName = camel(method.name.value); + const paramsType = this.buildMethodParamsType(method); + + yield ` ${methodName}: ${paramsType};`; + } + + yield ' };'; + } + + yield '}'; + } + + private *generateTypeHelpers(): Iterable { + // ServiceKeys type + yield '/**'; + yield ' * Extract all service names from QueryKeyMap'; + yield ' */'; + yield 'export type ServiceKeys = keyof QueryKeyMap;'; + yield ''; + + // OperationKeys type + yield '/**'; + yield ' * Extract operation names for a given service'; + yield ' */'; + yield 'export type OperationKeys = keyof QueryKeyMap[S];'; + yield ''; + + // OperationParams type + yield '/**'; + yield ' * Extract parameter type for a given service and operation'; + yield ' */'; + yield 'export type OperationParams<'; + yield ' S extends ServiceKeys,'; + yield ' O extends OperationKeys'; + yield '> = QueryKeyMap[S][O];'; + } + + private *generateMatchQueryKeyFunction(): Iterable { + yield '/**'; + yield ' * Build type-safe query keys for React Query cache operations'; + yield ' * '; + yield ' * @example'; + yield ' * // Match all queries for a service'; + yield ' * matchQueryKey("widget")'; + yield ' * // Returns: ["widget"]'; + yield ' * '; + yield ' * @example'; + yield ' * // Match all queries for a specific operation'; + yield ' * matchQueryKey("widget", "getWidgets")'; + yield ' * // Returns: ["widget", "getWidgets"]'; + yield ' * '; + yield ' * @example'; + yield ' * // Match specific query with parameters'; + yield ' * matchQueryKey("widget", "getWidgets", { status: "active" })'; + yield ' * // Returns: ["widget", "getWidgets", { status: "active" }]'; + yield ' */'; + + // Function overloads + yield 'export function matchQueryKey('; + yield ' service: S'; + yield '): readonly [S];'; + yield ''; + + yield 'export function matchQueryKey<'; + yield ' S extends ServiceKeys,'; + yield ' O extends OperationKeys'; + yield '>('; + yield ' service: S,'; + yield ' operation: O'; + yield '): readonly [S, O];'; + yield ''; + + yield 'export function matchQueryKey<'; + yield ' S extends ServiceKeys,'; + yield ' O extends OperationKeys'; + yield '>('; + yield ' service: S,'; + yield ' operation: O,'; + yield ' params: OperationParams extends undefined ? undefined : OperationParams'; + yield '): readonly [S, O, OperationParams extends undefined ? {} : OperationParams];'; + yield ''; + + // Implementation + yield 'export function matchQueryKey<'; + yield ' S extends ServiceKeys,'; + yield ' O extends OperationKeys'; + yield '>('; + yield ' service: S,'; + yield ' operation?: O,'; + yield ' params?: OperationParams'; + yield ') {'; + yield ' if (arguments.length === 3 && operation !== undefined) {'; + yield ' // When called with 3 arguments, always include params (use {} if undefined)'; + yield ' const finalParams = params === undefined ? {} : params;'; + yield ' return [service, operation, finalParams] as const;'; + yield ' }'; + yield ' if (operation !== undefined) {'; + yield ' return [service, operation] as const;'; + yield ' }'; + yield ' return [service] as const;'; + yield '}'; + } + + private buildMethodParamsType(method: Method): string { + const paramsType = from(buildParamsType(method)); + + if (!paramsType) { + return 'undefined'; + } + + // Register the type with the import builder + this.types.type(paramsType); + + const hasRequired = method.parameters.some((p) => isRequired(p)); + return hasRequired ? paramsType : `${paramsType} | undefined`; + } +} diff --git a/src/snapshot/v1/hooks/auth-permutations.ts b/src/snapshot/v1/hooks/auth-permutations.ts new file mode 100644 index 0000000..00d4d29 --- /dev/null +++ b/src/snapshot/v1/hooks/auth-permutations.ts @@ -0,0 +1,130 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import { + mutationOptions, + queryOptions, + type UndefinedInitialDataOptions, + useMutation, + type UseMutationOptions, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { ComboAuthSchemesParams } from '../types'; +import { + getAuthPermutationService, + useAuthPermutationService, +} from './context'; +import { CompositeError } from './runtime'; + +/** + * @deprecated + */ +export function useAllAuthSchemes( + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useAllAuthSchemesQueryOptions(); + return useQuery({ ...defaultOptions, ...options }); +} + +/** + * @deprecated + */ +export function useSuspenseAllAuthSchemes( + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useAllAuthSchemesQueryOptions(); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +/** + * @deprecated + */ +export function useComboAuthSchemes( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const authPermutationService = useAuthPermutationService(); + return useMutation({ + mutationFn: async () => { + const res = await authPermutationService.comboAuthSchemes(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + queryClient.invalidateQueries({ queryKey: ['authPermutation'] }); + return res.data; + }, + ...options, + }); +} + +const useAllAuthSchemesQueryOptions = () => { + const authPermutationService = useAuthPermutationService(); + return queryOptions({ + queryKey: ['authPermutation', 'allAuthSchemes', {}], + queryFn: async () => { + const res = await authPermutationService.allAuthSchemes(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; + +export const allAuthSchemesQueryOptions = () => { + const authPermutationService = getAuthPermutationService(); + return queryOptions({ + queryKey: ['authPermutation', 'allAuthSchemes', {}], + queryFn: async () => { + const res = await authPermutationService.allAuthSchemes(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; +export const comboAuthSchemesMutationOptions = () => { + const authPermutationService = getAuthPermutationService(); + return mutationOptions({ + mutationFn: async () => { + const res = await authPermutationService.comboAuthSchemes(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; diff --git a/src/snapshot/v1/hooks/context.tsx b/src/snapshot/v1/hooks/context.tsx new file mode 100644 index 0000000..3251d35 --- /dev/null +++ b/src/snapshot/v1/hooks/context.tsx @@ -0,0 +1,172 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import { + createContext, + type FC, + type PropsWithChildren, + useContext, + useMemo, +} from 'react'; +import { + type BasketryExampleOptions, + type FetchLike, + HttpAuthPermutationService, + HttpExhaustiveService, + HttpGizmoService, + HttpWidgetService, +} from '../http-client'; +import type { + AuthPermutationService, + ExhaustiveService, + GizmoService, + WidgetService, +} from '../types'; + +export interface BasketryExampleContextProps { + fetch: FetchLike; + options: BasketryExampleOptions; +} +const BasketryExampleContext = createContext< + BasketryExampleContextProps | undefined +>(undefined); + +let currentContext: BasketryExampleContextProps | undefined; + +export const BasketryExampleProvider: FC< + PropsWithChildren +> = ({ children, fetch, options }) => { + const value = useMemo( + () => ({ fetch, options }), + [ + fetch, + options.mapUnhandledException, + options.mapValidationError, + options.root, + ], + ); + currentContext = value; + return ( + + {children} + + ); +}; + +export const getGizmoService = () => { + if (!currentContext) { + throw new Error( + 'getGizmoService called outside of BasketryExampleProvider', + ); + } + const gizmoService: GizmoService = new HttpGizmoService( + currentContext.fetch, + currentContext.options, + ); + return gizmoService; +}; + +export const useGizmoService = () => { + const context = useContext(BasketryExampleContext); + if (!context) { + throw new Error( + 'useGizmoService must be used within a BasketryExampleProvider', + ); + } + const gizmoService: GizmoService = new HttpGizmoService( + context.fetch, + context.options, + ); + return gizmoService; +}; + +export const getWidgetService = () => { + if (!currentContext) { + throw new Error( + 'getWidgetService called outside of BasketryExampleProvider', + ); + } + const widgetService: WidgetService = new HttpWidgetService( + currentContext.fetch, + currentContext.options, + ); + return widgetService; +}; + +export const useWidgetService = () => { + const context = useContext(BasketryExampleContext); + if (!context) { + throw new Error( + 'useWidgetService must be used within a BasketryExampleProvider', + ); + } + const widgetService: WidgetService = new HttpWidgetService( + context.fetch, + context.options, + ); + return widgetService; +}; + +export const getExhaustiveService = () => { + if (!currentContext) { + throw new Error( + 'getExhaustiveService called outside of BasketryExampleProvider', + ); + } + const exhaustiveService: ExhaustiveService = new HttpExhaustiveService( + currentContext.fetch, + currentContext.options, + ); + return exhaustiveService; +}; + +export const useExhaustiveService = () => { + const context = useContext(BasketryExampleContext); + if (!context) { + throw new Error( + 'useExhaustiveService must be used within a BasketryExampleProvider', + ); + } + const exhaustiveService: ExhaustiveService = new HttpExhaustiveService( + context.fetch, + context.options, + ); + return exhaustiveService; +}; + +export const getAuthPermutationService = () => { + if (!currentContext) { + throw new Error( + 'getAuthPermutationService called outside of BasketryExampleProvider', + ); + } + const authPermutationService: AuthPermutationService = + new HttpAuthPermutationService( + currentContext.fetch, + currentContext.options, + ); + return authPermutationService; +}; + +export const useAuthPermutationService = () => { + const context = useContext(BasketryExampleContext); + if (!context) { + throw new Error( + 'useAuthPermutationService must be used within a BasketryExampleProvider', + ); + } + const authPermutationService: AuthPermutationService = + new HttpAuthPermutationService(context.fetch, context.options); + return authPermutationService; +}; diff --git a/src/snapshot/v1/hooks/exhaustives.ts b/src/snapshot/v1/hooks/exhaustives.ts new file mode 100644 index 0000000..bfd9a78 --- /dev/null +++ b/src/snapshot/v1/hooks/exhaustives.ts @@ -0,0 +1,170 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import { + queryOptions, + type UndefinedInitialDataOptions, + useQuery, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { ExhaustiveFormatsParams, ExhaustiveParamsParams } from '../types'; +import { getExhaustiveService, useExhaustiveService } from './context'; +import { CompositeError } from './runtime'; + +/** + * @deprecated + */ +export function useExhaustiveFormats( + params?: ExhaustiveFormatsParams, + options?: Omit< + UndefinedInitialDataOptions< + void, + Error, + void | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useExhaustiveFormatsQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +} + +/** + * @deprecated + */ +export function useSuspenseExhaustiveFormats( + params?: ExhaustiveFormatsParams, + options?: Omit< + UndefinedInitialDataOptions< + void, + Error, + void | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useExhaustiveFormatsQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +/** + * @deprecated + */ +export function useExhaustiveParams( + params: ExhaustiveParamsParams, + options?: Omit< + UndefinedInitialDataOptions< + void, + Error, + void | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useExhaustiveParamsQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +} + +/** + * @deprecated + */ +export function useSuspenseExhaustiveParams( + params: ExhaustiveParamsParams, + options?: Omit< + UndefinedInitialDataOptions< + void, + Error, + void | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useExhaustiveParamsQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +const useExhaustiveFormatsQueryOptions = (params?: ExhaustiveFormatsParams) => { + const exhaustiveService = useExhaustiveService(); + return queryOptions({ + queryKey: ['exhaustive', 'exhaustiveFormats', params || {}], + queryFn: async () => { + const res = await exhaustiveService.exhaustiveFormats(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; + +export const exhaustiveFormatsQueryOptions = ( + params?: ExhaustiveFormatsParams, +) => { + const exhaustiveService = getExhaustiveService(); + return queryOptions({ + queryKey: ['exhaustive', 'exhaustiveFormats', params || {}], + queryFn: async () => { + const res = await exhaustiveService.exhaustiveFormats(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; +const useExhaustiveParamsQueryOptions = (params: ExhaustiveParamsParams) => { + const exhaustiveService = useExhaustiveService(); + return queryOptions({ + queryKey: ['exhaustive', 'exhaustiveParams', params || {}], + queryFn: async () => { + const res = await exhaustiveService.exhaustiveParams(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; + +export const exhaustiveParamsQueryOptions = ( + params: ExhaustiveParamsParams, +) => { + const exhaustiveService = getExhaustiveService(); + return queryOptions({ + queryKey: ['exhaustive', 'exhaustiveParams', params || {}], + queryFn: async () => { + const res = await exhaustiveService.exhaustiveParams(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; diff --git a/src/snapshot/v1/hooks/gizmos.ts b/src/snapshot/v1/hooks/gizmos.ts new file mode 100644 index 0000000..b556fb0 --- /dev/null +++ b/src/snapshot/v1/hooks/gizmos.ts @@ -0,0 +1,239 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import { + mutationOptions, + queryOptions, + type UndefinedInitialDataOptions, + useMutation, + type UseMutationOptions, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { + CreateGizmoParams, + GetGizmosParams, + Gizmo, + GizmosResponse, + UpdateGizmoParams, + UploadGizmoParams, +} from '../types'; +import { getGizmoService, useGizmoService } from './context'; +import { CompositeError } from './runtime'; + +/** + * Has a summary in addition to a description + * Has a description in addition to a summary + * @deprecated + */ +export function useCreateGizmo( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const gizmoService = useGizmoService(); + return useMutation({ + mutationFn: async (params?: CreateGizmoParams) => { + const res = await gizmoService.createGizmo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + queryClient.invalidateQueries({ queryKey: ['gizmo'] }); + return res.data; + }, + ...options, + }); +} + +/** + * Only has a summary + * @deprecated + */ +export function useGizmos( + params?: GetGizmosParams, + options?: Omit< + UndefinedInitialDataOptions< + GizmosResponse, + Error, + Gizmo | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useGetGizmosQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +} + +/** + * Only has a summary + * @deprecated + */ +export function useSuspenseGizmos( + params?: GetGizmosParams, + options?: Omit< + UndefinedInitialDataOptions< + GizmosResponse, + Error, + Gizmo | undefined, + (string | Record)[] + >, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useGetGizmosQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +/** + * @deprecated + */ +export function useUpdateGizmo( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const gizmoService = useGizmoService(); + return useMutation({ + mutationFn: async (params?: UpdateGizmoParams) => { + const res = await gizmoService.updateGizmo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + queryClient.invalidateQueries({ queryKey: ['gizmo'] }); + return res.data; + }, + ...options, + }); +} + +/** + * @deprecated + */ +export function useUploadGizmo( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const gizmoService = useGizmoService(); + return useMutation({ + mutationFn: async (params: UploadGizmoParams) => { + const res = await gizmoService.uploadGizmo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + queryClient.invalidateQueries({ queryKey: ['gizmo'] }); + return res.data; + }, + ...options, + }); +} + +const useGetGizmosQueryOptions = (params?: GetGizmosParams) => { + const gizmoService = useGizmoService(); + return queryOptions({ + queryKey: ['gizmo', 'getGizmos', params || {}], + queryFn: async () => { + const res = await gizmoService.getGizmos(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; + +/** + * Only has a summary + * @deprecated + */ +export const getGizmosQueryOptions = (params?: GetGizmosParams) => { + const gizmoService = getGizmoService(); + return queryOptions({ + queryKey: ['gizmo', 'getGizmos', params || {}], + queryFn: async () => { + const res = await gizmoService.getGizmos(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + select: (data) => data.data, + }); +}; + +/** + * Has a summary in addition to a description + * Has a description in addition to a summary + */ +export const createGizmoMutationOptions = () => { + const gizmoService = getGizmoService(); + return mutationOptions({ + mutationFn: async (params: CreateGizmoParams) => { + const res = await gizmoService.createGizmo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; +export const updateGizmoMutationOptions = () => { + const gizmoService = getGizmoService(); + return mutationOptions({ + mutationFn: async (params: UpdateGizmoParams) => { + const res = await gizmoService.updateGizmo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; +export const uploadGizmoMutationOptions = () => { + const gizmoService = getGizmoService(); + return mutationOptions({ + mutationFn: async (params: UploadGizmoParams) => { + const res = await gizmoService.uploadGizmo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; diff --git a/src/snapshot/v1/hooks/query-key-builder.ts b/src/snapshot/v1/hooks/query-key-builder.ts new file mode 100644 index 0000000..9ab4250 --- /dev/null +++ b/src/snapshot/v1/hooks/query-key-builder.ts @@ -0,0 +1,129 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import type { + AllAuthSchemesParams, + ComboAuthSchemesParams, + CreateGizmoParams, + CreateWidgetParams, + DeleteWidgetFooParams, + ExhaustiveFormatsParams, + ExhaustiveParamsParams, + GetGizmosParams, + GetWidgetFooParams, + GetWidgetsParams, + PutWidgetParams, + UpdateGizmoParams, + UploadGizmoParams, +} from '../types'; + +/** + * Type mapping for all available query keys in the service + */ +export interface QueryKeyMap { + gizmo: { + getGizmos: GetGizmosParams | undefined; + createGizmo: CreateGizmoParams | undefined; + updateGizmo: UpdateGizmoParams | undefined; + uploadGizmo: UploadGizmoParams; + }; + widget: { + getWidgets: GetWidgetsParams | undefined; + createWidget: CreateWidgetParams | undefined; + putWidget: PutWidgetParams | undefined; + getWidgetFoo: GetWidgetFooParams; + deleteWidgetFoo: DeleteWidgetFooParams; + }; + exhaustive: { + exhaustiveFormats: ExhaustiveFormatsParams | undefined; + exhaustiveParams: ExhaustiveParamsParams; + }; + authPermutation: { + allAuthSchemes: AllAuthSchemesParams | undefined; + comboAuthSchemes: ComboAuthSchemesParams | undefined; + }; +} + +/** + * Extract all service names from QueryKeyMap + */ +export type ServiceKeys = keyof QueryKeyMap; + +/** + * Extract operation names for a given service + */ +export type OperationKeys = keyof QueryKeyMap[S]; + +/** + * Extract parameter type for a given service and operation + */ +export type OperationParams< + S extends ServiceKeys, + O extends OperationKeys, +> = QueryKeyMap[S][O]; + +/** + * Build type-safe query keys for React Query cache operations + * + * @example + * // Match all queries for a service + * matchQueryKey("widget") + * // Returns: ["widget"] + * + * @example + * // Match all queries for a specific operation + * matchQueryKey("widget", "getWidgets") + * // Returns: ["widget", "getWidgets"] + * + * @example + * // Match specific query with parameters + * matchQueryKey("widget", "getWidgets", { status: "active" }) + * // Returns: ["widget", "getWidgets", { status: "active" }] + */ +export function matchQueryKey(service: S): readonly [S]; + +export function matchQueryKey< + S extends ServiceKeys, + O extends OperationKeys, +>(service: S, operation: O): readonly [S, O]; + +export function matchQueryKey< + S extends ServiceKeys, + O extends OperationKeys, +>( + service: S, + operation: O, + params: OperationParams extends undefined + ? undefined + : OperationParams, +): readonly [ + S, + O, + OperationParams extends undefined ? {} : OperationParams, +]; + +export function matchQueryKey< + S extends ServiceKeys, + O extends OperationKeys, +>(service: S, operation?: O, params?: OperationParams) { + if (arguments.length === 3 && operation !== undefined) { + // When called with 3 arguments, always include params (use {} if undefined) + const finalParams = params === undefined ? {} : params; + return [service, operation, finalParams] as const; + } + if (operation !== undefined) { + return [service, operation] as const; + } + return [service] as const; +} diff --git a/src/snapshot/v1/hooks/runtime.ts b/src/snapshot/v1/hooks/runtime.ts new file mode 100644 index 0000000..7bc72bc --- /dev/null +++ b/src/snapshot/v1/hooks/runtime.ts @@ -0,0 +1,109 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import type { + GetNextPageParamFunction, + GetPreviousPageParamFunction, +} from '@tanstack/react-query'; + +export type PageParam = { pageParam?: string }; + +export class CompositeError extends Error { + constructor(readonly errors: { title: string }[]) { + super(errors.map((e) => e.title).join(', ')); + if (Error.captureStackTrace) Error.captureStackTrace(this, CompositeError); + } +} + +export type RelayParams = { + first?: number; + after?: string; + last?: number; + before?: string; +}; + +export type Response = { + pageInfo?: { + startCursor?: string; + hasPreviousPage: boolean; + hasNextPage: boolean; + endCursor?: string; + }; +}; + +export const getNextPageParam: GetNextPageParamFunction< + string | undefined, + Response +> = (lastPage) => { + return lastPage.pageInfo?.hasNextPage + ? `after:${lastPage.pageInfo.endCursor}` + : undefined; +}; + +export const getPreviousPageParam: GetPreviousPageParamFunction< + string | undefined, + Response +> = (lastPage) => { + return lastPage.pageInfo?.hasPreviousPage + ? `before:${lastPage.pageInfo.startCursor}` + : undefined; +}; + +export function applyPageParam( + params: T, + pageParam: string | undefined, +): T { + const { first, after, last, before, ...rest } = params; + const syntheticParams: T = rest as T; + + if (pageParam) { + const [key, value] = pageParam.split(':'); + + if (key === 'after') { + syntheticParams.first = first ?? last; + syntheticParams.after = value; + } else if (key === 'before') { + syntheticParams.last = last ?? first; + syntheticParams.before = value; + } + } else { + if (first) syntheticParams.first = first; + if (after) syntheticParams.after = after; + if (last) syntheticParams.last = last; + if (before) syntheticParams.before = before; + } + + return syntheticParams; +} + +export function getInitialPageParam(params: { + after?: string; + before?: string; +}): string | undefined { + if (params.after) return `after:${params.after}`; + if (params.before) return `before:${params.before}`; + return; +} + +export function compact( + params: Record, +): Record | undefined { + const result: Record = Object.fromEntries( + Object.entries(params).filter( + ([, value]) => value !== null && value !== undefined, + ), + ) as any; + + return Object.keys(result).length ? result : undefined; +} diff --git a/src/snapshot/v1/hooks/widgets.ts b/src/snapshot/v1/hooks/widgets.ts new file mode 100644 index 0000000..b5a116a --- /dev/null +++ b/src/snapshot/v1/hooks/widgets.ts @@ -0,0 +1,270 @@ +/** + * This code was generated by @basketry/react-query@{{version}} + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * To make changes to the contents of this file: + * 1. Edit source/path.ext + * 2. Run the Basketry CLI + * + * About Basketry: https://github.com/basketry/basketry/wiki + * About @basketry/react-query: https://github.com/basketry/react-query#readme + */ + +import { + mutationOptions, + queryOptions, + type UndefinedInitialDataOptions, + useMutation, + type UseMutationOptions, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query'; +import type { + CreateWidgetParams, + DeleteWidgetFooParams, + GetWidgetFooParams, + PutWidgetParams, + Widget, +} from '../types'; +import { getWidgetService, useWidgetService } from './context'; +import { CompositeError } from './runtime'; + +/** + * @deprecated + */ +export function useCreateWidget( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const widgetService = useWidgetService(); + return useMutation({ + mutationFn: async (params?: CreateWidgetParams) => { + const res = await widgetService.createWidget(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + queryClient.invalidateQueries({ queryKey: ['widget'] }); + return res.data; + }, + ...options, + }); +} + +/** + * @deprecated + */ +export function useDeleteWidgetFoo( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const widgetService = useWidgetService(); + return useMutation({ + mutationFn: async (params: DeleteWidgetFooParams) => { + const res = await widgetService.deleteWidgetFoo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + queryClient.invalidateQueries({ queryKey: ['widget'] }); + return res.data; + }, + ...options, + }); +} + +/** + * @deprecated + */ +export function usePutWidget( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) { + const queryClient = useQueryClient(); + const widgetService = useWidgetService(); + return useMutation({ + mutationFn: async () => { + const res = await widgetService.putWidget(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + queryClient.invalidateQueries({ queryKey: ['widget'] }); + return res.data; + }, + ...options, + }); +} + +/** + * @deprecated + */ +export function useWidgetFoo( + params: GetWidgetFooParams, + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useGetWidgetFooQueryOptions(params); + return useQuery({ ...defaultOptions, ...options }); +} + +/** + * @deprecated + */ +export function useSuspenseWidgetFoo( + params: GetWidgetFooParams, + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useGetWidgetFooQueryOptions(params); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +/** + * @deprecated + */ +export function useWidgets( + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useGetWidgetsQueryOptions(); + return useQuery({ ...defaultOptions, ...options }); +} + +/** + * @deprecated + */ +export function useSuspenseWidgets( + options?: Omit< + UndefinedInitialDataOptions, + 'queryKey' | 'queryFn' | 'select' + >, +) { + const defaultOptions = useGetWidgetsQueryOptions(); + return useSuspenseQuery({ ...defaultOptions, ...options }); +} + +const useGetWidgetsQueryOptions = () => { + const widgetService = useWidgetService(); + return queryOptions({ + queryKey: ['widget', 'getWidgets', {}], + queryFn: async () => { + const res = await widgetService.getWidgets(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + }); +}; + +export const getWidgetsQueryOptions = () => { + const widgetService = getWidgetService(); + return queryOptions({ + queryKey: ['widget', 'getWidgets', {}], + queryFn: async () => { + const res = await widgetService.getWidgets(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + }); +}; +export const createWidgetMutationOptions = () => { + const widgetService = getWidgetService(); + return mutationOptions({ + mutationFn: async (params: CreateWidgetParams) => { + const res = await widgetService.createWidget(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; +export const putWidgetMutationOptions = () => { + const widgetService = getWidgetService(); + return mutationOptions({ + mutationFn: async () => { + const res = await widgetService.putWidget(); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; +const useGetWidgetFooQueryOptions = (params: GetWidgetFooParams) => { + const widgetService = useWidgetService(); + return queryOptions({ + queryKey: ['widget', 'getWidgetFoo', params || {}], + queryFn: async () => { + const res = await widgetService.getWidgetFoo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + }); +}; + +export const getWidgetFooQueryOptions = (params: GetWidgetFooParams) => { + const widgetService = getWidgetService(); + return queryOptions({ + queryKey: ['widget', 'getWidgetFoo', params || {}], + queryFn: async () => { + const res = await widgetService.getWidgetFoo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res; + }, + }); +}; +export const deleteWidgetFooMutationOptions = () => { + const widgetService = getWidgetService(); + return mutationOptions({ + mutationFn: async (params: DeleteWidgetFooParams) => { + const res = await widgetService.deleteWidgetFoo(params); + if (res.errors.length) { + throw new CompositeError(res.errors); + } else if (!res.data) { + throw new Error('Unexpected data error: Failed to get example'); + } + return res.data; + }, + }); +}; diff --git a/tsconfig.json b/tsconfig.json index d8f8bc7..b6462f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "strictNullChecks": true }, "include": ["src"], - "exclude": ["**/*.test?.*"] + "exclude": ["**/*.test?.*", "src/snapshot/**/*"] }