diff --git a/packages/swr/package.json b/packages/swr/package.json index a7f72565d..61b2b78b6 100644 --- a/packages/swr/package.json +++ b/packages/swr/package.json @@ -18,6 +18,7 @@ "build": "tsdown --config-loader unrun", "dev": "tsdown --config-loader unrun --watch src", "lint": "eslint .", + "test": "vitest", "clean": "rimraf .turbo dist", "nuke": "rimraf .turbo dist node_modules" }, @@ -28,7 +29,8 @@ "devDependencies": { "eslint": "catalog:", "rimraf": "catalog:", - "tsdown": "catalog:" + "tsdown": "catalog:", + "vitest": "catalog:" }, "stableVersion": "8.0.0-rc.1" } diff --git a/packages/swr/src/client.test.ts b/packages/swr/src/client.test.ts new file mode 100644 index 000000000..d2cd1fcf8 --- /dev/null +++ b/packages/swr/src/client.test.ts @@ -0,0 +1,62 @@ +import type { GetterProp } from '@orval/core'; +import { GetterPropType } from '@orval/core'; +import { describe, expect, it } from 'vitest'; + +describe('query parameter type extraction', () => { + it('extracts type name from query param GetterProp', () => { + const queryParamProp: GetterProp = { + name: 'params', + definition: 'params: ListPetsParams', + implementation: 'params: ListPetsParams', + default: false, + required: true, + type: GetterPropType.QUERY_PARAM, + }; + + const props: GetterProp[] = [queryParamProp]; + const queryParam = props.find( + (prop) => prop.type === GetterPropType.QUERY_PARAM, + ); + const extractedType = queryParam?.definition.split(': ')[1] ?? 'never'; + + expect(extractedType).toBe('ListPetsParams'); + }); + + it('extracts type name from optional query param', () => { + const queryParamProp: GetterProp = { + name: 'params', + definition: 'params?: GetUsersParams', + implementation: 'params?: GetUsersParams', + default: false, + required: false, + type: GetterPropType.QUERY_PARAM, + }; + + const props: GetterProp[] = [queryParamProp]; + const queryParam = props.find( + (prop) => prop.type === GetterPropType.QUERY_PARAM, + ); + const extractedType = queryParam?.definition.split(': ')[1] ?? 'never'; + + expect(extractedType).toBe('GetUsersParams'); + }); + + it('returns never when no query param exists', () => { + const pathParamProp: GetterProp = { + name: 'id', + definition: 'id: string', + implementation: 'id: string', + default: false, + required: true, + type: GetterPropType.PARAM, + }; + + const props: GetterProp[] = [pathParamProp]; + const queryParam = props.find( + (prop) => prop.type === GetterPropType.QUERY_PARAM, + ); + const extractedType = queryParam?.definition.split(': ')[1] ?? 'never'; + + expect(extractedType).toBe('never'); + }); +}); diff --git a/packages/swr/src/index.test.ts b/packages/swr/src/index.test.ts new file mode 100644 index 000000000..ca803e48c --- /dev/null +++ b/packages/swr/src/index.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { builder } from './index'; + +describe('swr builder', () => { + it('returns a valid builder function', () => { + const result = builder(); + expect(result).toBeDefined(); + expect(typeof result).toBe('function'); + }); + + it('builder returns client builder with required methods', () => { + const result = builder()(); + expect(result).toBeDefined(); + expect(result.client).toBeDefined(); + expect(result.dependencies).toBeDefined(); + expect(result.header).toBeDefined(); + expect(typeof result.client).toBe('function'); + expect(typeof result.dependencies).toBe('function'); + expect(typeof result.header).toBe('function'); + }); +}); diff --git a/packages/swr/src/index.ts b/packages/swr/src/index.ts index 3c9e6e596..5e71f49e9 100644 --- a/packages/swr/src/index.ts +++ b/packages/swr/src/index.ts @@ -162,6 +162,10 @@ const generateSwrImplementation = ({ props, doc, httpClient, + pathOnlyParams, + headerOnlyParams, + hasQueryParams, + queryParamType, }: { isRequestOptions: boolean; operationName: string; @@ -176,6 +180,10 @@ const generateSwrImplementation = ({ swrOptions: SwrOptions; doc?: string; httpClient: OutputHttpClient; + pathOnlyParams: string; + headerOnlyParams: string; + hasQueryParams: boolean; + queryParamType: string; }) => { const swrProps = toObjectString(props, 'implementation'); @@ -192,7 +200,7 @@ const generateSwrImplementation = ({ : '' }`; const swrKeyImplementation = `const swrKey = swrOptions?.swrKey ?? (() => isEnabled ? ${swrKeyFnName}(${swrKeyProperties}) : null);`; - const swrKeyLoaderImplementation = `const swrKeyLoader = swrOptions?.swrKeyLoader ?? (() => isEnabled ? ${swrKeyLoaderFnName}(${swrKeyProperties}) : null);`; + const swrKeyLoaderImplementation = `const swrKeyLoader = swrOptions?.swrKeyLoader ?? (isEnabled ? ${swrKeyLoaderFnName}(${swrKeyProperties}) : () => null);`; const errorType = getSwrErrorType(response, httpClient, mutator); const swrRequestSecondArg = getSwrRequestSecondArg(httpClient, mutator); @@ -223,9 +231,11 @@ ${doc}export const ${camel( ${enabledImplementation} ${swrKeyLoaderImplementation} - const swrFn = () => ${operationName}(${httpFunctionProps}${ - httpFunctionProps && httpRequestSecondArg ? ', ' : '' - }${httpRequestSecondArg}) + const swrFn = ${ + hasQueryParams + ? `([_url, pageParams]: [string, ${queryParamType} & { page: number }]) => ${operationName}(${pathOnlyParams}${pathOnlyParams ? ', ' : ''}pageParams${headerOnlyParams ? ', ' + headerOnlyParams : ''}${httpRequestSecondArg ? ', ' + httpRequestSecondArg : ''})` + : `([_url]: [string]) => ${operationName}(${pathOnlyParams}${headerOnlyParams ? (pathOnlyParams ? ', ' : '') + headerOnlyParams : ''}${httpRequestSecondArg ? (pathOnlyParams || headerOnlyParams ? ', ' : '') + httpRequestSecondArg : ''})` + } const ${queryResultVarName} = useSWRInfinite>, TError>(swrKeyLoader, swrFn, ${ swrOptions.swrInfiniteOptions @@ -406,7 +416,8 @@ const generateSwrHook = ( (prop) => prop.type === GetterPropType.PARAM || prop.type === GetterPropType.QUERY_PARAM || - prop.type === GetterPropType.NAMED_PATH_PARAMS, + prop.type === GetterPropType.NAMED_PATH_PARAMS || + prop.type === GetterPropType.HEADER, ), 'implementation', ); @@ -416,7 +427,8 @@ const generateSwrHook = ( (prop) => prop.type === GetterPropType.PARAM || prop.type === GetterPropType.QUERY_PARAM || - prop.type === GetterPropType.NAMED_PATH_PARAMS, + prop.type === GetterPropType.NAMED_PATH_PARAMS || + prop.type === GetterPropType.HEADER, ) .map((param) => { return param.type === GetterPropType.NAMED_PATH_PARAMS @@ -463,6 +475,35 @@ const generateSwrHook = ( }) .join(','); + // For useSWRInfinite: separate path params from query params + const pathOnlyParams = props + .filter( + (prop) => + prop.type === GetterPropType.PARAM || + prop.type === GetterPropType.NAMED_PATH_PARAMS, + ) + .map((param) => { + return param.type === GetterPropType.NAMED_PATH_PARAMS + ? param.destructured + : param.name; + }) + .join(','); + + const headerOnlyParams = props + .filter((prop) => prop.type === GetterPropType.HEADER) + .map((param) => param.name) + .join(','); + + const hasQueryParams = props.some( + (prop) => prop.type === GetterPropType.QUERY_PARAM, + ); + + // Extract just the type name from definition (e.g., "params: ListPetsParams" -> "ListPetsParams") + const queryParamType = + props + .find((prop) => prop.type === GetterPropType.QUERY_PARAM) + ?.definition.split(': ')[1] ?? 'never'; + const queryKeyProps = toObjectString( props.filter((prop) => prop.type !== GetterPropType.HEADER), 'implementation', @@ -480,7 +521,7 @@ export const ${swrKeyFnName} = (${queryKeyProps}) => [\`${route}\`${ ); const swrKeyLoader = override.swr.useInfinite ? `export const ${swrKeyLoaderFnName} = (${queryKeyProps}) => { - return (page: number, previousPageData: Awaited>) => { + return (page: number, previousPageData?: Awaited>) => { if (previousPageData && !previousPageData.data) return null return [\`${route}\`${queryParams ? ', ...(params ? [{...params,page}]: [{page}])' : ''}${ @@ -504,6 +545,10 @@ export const ${swrKeyFnName} = (${queryKeyProps}) => [\`${route}\`${ swrOptions: override.swr, doc, httpClient, + pathOnlyParams, + headerOnlyParams, + hasQueryParams, + queryParamType, }); if (!override.swr.useSWRMutationForGet) { @@ -511,7 +556,7 @@ export const ${swrKeyFnName} = (${queryKeyProps}) => [\`${route}\`${ } // For OutputClient.SWR_GET_MUTATION, generate both useSWR and useSWRMutation - const httpFnPropertiesForGet = props + const httpFnPropertiesForGetWithoutHeaders = props .filter((prop) => prop.type !== GetterPropType.HEADER) .map((prop) => { return prop.type === GetterPropType.NAMED_PATH_PARAMS @@ -520,6 +565,18 @@ export const ${swrKeyFnName} = (${queryKeyProps}) => [\`${route}\`${ }) .join(', '); + const headerParamsForGet = props + .filter((prop) => prop.type === GetterPropType.HEADER) + .map((param) => param.name) + .join(', '); + + const httpFnPropertiesForGet = [ + httpFnPropertiesForGetWithoutHeaders, + headerParamsForGet, + ] + .filter(Boolean) + .join(', '); + const swrMutationFetcherType = getSwrMutationFetcherType( response, httpClient, @@ -575,7 +632,7 @@ export const ${swrMutationFetcherName} = (${queryKeyProps} ${swrMutationFetcherO swrMutationImplementation ); } else { - const httpFnProperties = props + const httpFnPropertiesWithoutHeaders = props .filter((prop) => prop.type !== GetterPropType.HEADER) .map((prop) => { if (prop.type === GetterPropType.NAMED_PATH_PARAMS) { @@ -588,6 +645,15 @@ export const ${swrMutationFetcherName} = (${queryKeyProps} ${swrMutationFetcherO }) .join(', '); + const headerParams = props + .filter((prop) => prop.type === GetterPropType.HEADER) + .map((param) => param.name) + .join(', '); + + const httpFnProperties = [httpFnPropertiesWithoutHeaders, headerParams] + .filter(Boolean) + .join(', '); + const swrKeyFnName = camel(`get-${operationName}-mutation-key`); const swrMutationKeyFn = `export const ${swrKeyFnName} = (${queryKeyProps}) => [\`${route}\`${ queryParams ? ', ...(params ? [params]: [])' : '' diff --git a/packages/swr/vitest.config.ts b/packages/swr/vitest.config.ts new file mode 100644 index 000000000..94ede10e2 --- /dev/null +++ b/packages/swr/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({}); diff --git a/tests/configs/swr.config.ts b/tests/configs/swr.config.ts index 5bf1ea96b..cc5d3bba0 100644 --- a/tests/configs/swr.config.ts +++ b/tests/configs/swr.config.ts @@ -186,6 +186,22 @@ export default defineConfig({ target: '../specifications/petstore.yaml', }, }, + petstoreWithHeaders: { + output: { + target: '../generated/swr/petstore-with-headers/endpoints.ts', + schemas: '../generated/swr/petstore-with-headers/model', + client: 'swr', + headers: true, + override: { + swr: { + useInfinite: true, + }, + }, + }, + input: { + target: '../specifications/petstore.yaml', + }, + }, blobFile: { output: { target: '../generated/swr/blob-file/endpoints.ts', diff --git a/tests/regressions/swr.ts b/tests/regressions/swr.ts new file mode 100644 index 000000000..8d409140f --- /dev/null +++ b/tests/regressions/swr.ts @@ -0,0 +1,131 @@ +import { + getListPetsInfiniteKeyLoader, + useListPetsInfinite, +} from '../generated/swr/petstore-override-swr/endpoints'; +import { + useListPets as useListPetsWithHeaders, + useListPetsInfinite as useListPetsInfiniteWithHeaders, + useCreatePets, + getCreatePetsMutationFetcher, +} from '../generated/swr/petstore-with-headers/endpoints'; +import { ListPetsXExample } from '../generated/swr/petstore-with-headers/model'; +import type { CreatePetsHeaders } from '../generated/swr/petstore-with-headers/model'; + +export const useInfiniteQueryTest = () => { + const { data } = useListPetsInfinite( + { + sort: 'name', + }, + { + swr: { + initialSize: 2, + }, + }, + ); + + // Test that pageParams has correct type (ListPetsParams) + // SWR Infinite data is an array of pages + const pages = data?.flatMap((page) => { + if ('data' in page && page.status === 200) { + return page.data.map((pet) => pet.name); + } + return []; + }); + + return pages; +}; + +// Test that pageParams type includes page: number +// This verifies the fix: the generated swrFn should accept pageParams with page property +export const testInfinitePageParamType = () => { + const keyLoader = getListPetsInfiniteKeyLoader({ sort: 'name' }); + + // For the first page, previousPageData is omitted (optional parameter) + const key = keyLoader(0); + + // The key should be a tuple: [url, params] + // TypeScript should know that params has page: number property + if (key) { + const [, params] = key; + const pageValue: number = params.page; + return pageValue; + } + return; +}; + +// Test that headers are properly passed to infinite hooks +// This verifies the fix: headers should be included between pageParams and options +export const testInfiniteWithHeaders = () => { + const headers = { 'X-EXAMPLE': ListPetsXExample.ONE }; + const { data } = useListPetsInfiniteWithHeaders({ sort: 'name' }, headers, { + swr: { + initialSize: 2, + }, + }); + + // Test that data has correct type + const pages = data?.flatMap((page) => { + if ('data' in page && page.status === 200) { + return page.data.map((pet) => pet.name); + } + return []; + }); + + return pages; +}; + +// Test that headers work in regular (non-infinite) hooks too +export const testRegularHookWithHeaders = () => { + const headers = { 'X-EXAMPLE': ListPetsXExample.ONE }; + const { data } = useListPetsWithHeaders({ sort: 'name' }, headers); + + // Test that data has correct type + if (data && 'data' in data && data.status === 200) { + return data.data.map((pet) => pet.name); + } + return []; +}; + +// Test that mutation hooks accept headers parameter +export const testMutationHookWithHeaders = () => { + const headers: CreatePetsHeaders = { 'X-EXAMPLE': ListPetsXExample.ONE }; + const { trigger } = useCreatePets({ sort: 'name' }, headers); + + // Type check: trigger should be defined + return Boolean(trigger); +}; + +// Test that mutation fetcher accepts headers parameter +export const testMutationFetcherSignature = () => { + const headers: CreatePetsHeaders = { 'X-EXAMPLE': ListPetsXExample.ONE }; + + // The mutation fetcher should accept headers as a parameter + const fetcher = getCreatePetsMutationFetcher({ sort: 'name' }, headers); + + // Type check: fetcher should be a function + return typeof fetcher === 'function'; +}; + +// Test that swrFn uses array destructuring for parameters +// This verifies the fix: SWR passes key as array, not spread arguments +export const testSwrFnArrayDestructuring = () => { + const keyLoader = getListPetsInfiniteKeyLoader({ sort: 'name' }); + + // Simulate what SWR does: call keyLoader to get the key + const key = keyLoader(0); + + if (key) { + // SWR would pass this key array as a single argument to swrFn + // The swrFn should destructure it as: ([_url, pageParams]) => ... + // TypeScript will catch if the destructuring pattern is wrong + const [url, params] = key; + + // Type assertions to verify correct types + const _url: string = url; + const _page: number = params.page; + + return typeof _url === 'string' && typeof _page === 'number'; + } + + return false; +}; diff --git a/yarn.lock b/yarn.lock index ab4378c76..9ea532da1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5400,6 +5400,7 @@ __metadata: eslint: "catalog:" rimraf: "catalog:" tsdown: "catalog:" + vitest: "catalog:" languageName: unknown linkType: soft