diff --git a/docs/rtk-query/usage/code-generation.mdx b/docs/rtk-query/usage/code-generation.mdx index f307339267..6243fc9525 100644 --- a/docs/rtk-query/usage/code-generation.mdx +++ b/docs/rtk-query/usage/code-generation.mdx @@ -158,6 +158,13 @@ const filteredConfig: ConfigFile = { } ``` +:::note +You can avoid transforming endpoint names to camel case by passing `exactOperationIds: true` to `generateEndpoints`. However, you will have to ensure the following: +- All endpoints in use have an operation ID +- You have no duplicate operation IDs +- None of your operation IDs include whitespace +::: + #### Endpoint overrides If an endpoint is generated as a mutation instead of a query or the other way round, you can override that: diff --git a/packages/rtk-query-codegen-openapi/src/generate.ts b/packages/rtk-query-codegen-openapi/src/generate.ts index 0cf52b6130..aa9b6df64f 100644 --- a/packages/rtk-query-codegen-openapi/src/generate.ts +++ b/packages/rtk-query-codegen-openapi/src/generate.ts @@ -56,11 +56,17 @@ function patternMatches(pattern?: TextMatcher) { }; } -function operationMatches(pattern?: EndpointMatcher) { +function operationMatches(pattern: EndpointMatcher | undefined, exactOperationIds: boolean) { const checkMatch = typeof pattern === 'function' ? pattern : patternMatches(pattern); return function matcher(operationDefinition: OperationDefinition) { if (!pattern) return true; - const operationName = getOperationName(operationDefinition); + if (exactOperationIds && operationDefinition.operation.operationId === undefined) { + // TODO: More descriptive error message with traceable information + throw new Error('exactOperationIds specified, but found operation missing operationId'); + } + const operationName = exactOperationIds + ? operationDefinition.operation.operationId! + : getOperationName(operationDefinition); return checkMatch(operationName, operationDefinition); }; } @@ -139,9 +145,10 @@ function generateRegexConstantsForType( export function getOverrides( operation: OperationDefinition, - endpointOverrides?: EndpointOverrides[] + endpointOverrides?: EndpointOverrides[], + exactOperationIds: boolean = false ): EndpointOverrides | undefined { - return endpointOverrides?.find((override) => operationMatches(override.pattern)(operation)); + return endpointOverrides?.find((override) => operationMatches(override.pattern, exactOperationIds)(operation)); } export async function generateApi( @@ -170,6 +177,7 @@ export async function generateApi( useUnknown = false, esmExtensions = false, outputRegexConstants = false, + exactOperationIds = false, }: GenerationOptions ) { const v3Doc = (v3DocCache[spec] ??= await getV3Doc(spec, httpResolverOptions)); @@ -186,7 +194,20 @@ export async function generateApi( apiGen.preprocessComponents(apiGen.spec.components.schemas); } - const operationDefinitions = getOperationDefinitions(v3Doc).filter(operationMatches(filterEndpoints)); + const operationDefinitions = getOperationDefinitions(v3Doc).filter( + operationMatches(filterEndpoints, exactOperationIds) + ); + + if (exactOperationIds) { + const allOperationIds = operationDefinitions.map((o) => o.operation.operationId!); + const duplicateOperationIds = allOperationIds.filter( + (operationId, index) => allOperationIds.findIndex((id) => id === operationId) !== index + ); + if (duplicateOperationIds.length > 0) { + // TODO: More descriptive error message with traceable information + throw new Error('Duplicate operation IDs not allowed when using exactOperationIds'); + } + } const resultFile = ts.createSourceFile( 'someFileName.ts', @@ -239,7 +260,8 @@ export async function generateApi( operationDefinitions.map((operationDefinition) => generateEndpoint({ operationDefinition, - overrides: getOverrides(operationDefinition, endpointOverrides), + overrides: getOverrides(operationDefinition, endpointOverrides, exactOperationIds), + exactOperationIds, }) ), true @@ -278,6 +300,7 @@ export async function generateApi( endpointOverrides, config: hooks, operationNameSuffix, + exactOperationIds, }), ] : []), @@ -303,9 +326,11 @@ export async function generateApi( function generateEndpoint({ operationDefinition, overrides, + exactOperationIds, }: { operationDefinition: OperationDefinition; overrides?: EndpointOverrides; + exactOperationIds: boolean; }) { const { verb, @@ -314,7 +339,9 @@ export async function generateApi( operation, operation: { responses, requestBody }, } = operationDefinition; - const operationName = getOperationName({ verb, path, operation }); + const operationName = exactOperationIds + ? operation.operationId! // This is a safe operation when using exactOperationIds, as all operation IDs have already been confirmed to exist + : getOperationName({ verb, path, operation }); const tags = tag ? getTags({ verb, pathItem }) : undefined; const isQuery = testIsQuery(verb, overrides); diff --git a/packages/rtk-query-codegen-openapi/src/generators/react-hooks.ts b/packages/rtk-query-codegen-openapi/src/generators/react-hooks.ts index 91b5baef81..57d52c3564 100644 --- a/packages/rtk-query-codegen-openapi/src/generators/react-hooks.ts +++ b/packages/rtk-query-codegen-openapi/src/generators/react-hooks.ts @@ -12,6 +12,7 @@ type GetReactHookNameParams = { endpointOverrides: EndpointOverrides[] | undefined; config: HooksConfigOptions; operationNameSuffix?: string; + exactOperationIds: boolean; }; type CreateBindingParams = { @@ -19,6 +20,7 @@ type CreateBindingParams = { overrides?: EndpointOverrides; isLazy?: boolean; operationNameSuffix?: string; + exactOperationIds: boolean; }; const createBinding = ({ @@ -26,25 +28,33 @@ const createBinding = ({ overrides, isLazy = false, operationNameSuffix, + exactOperationIds, }: CreateBindingParams) => factory.createBindingElement( undefined, undefined, factory.createIdentifier( - `use${isLazy ? 'Lazy' : ''}${capitalize(getOperationName(verb, path, operation.operationId))}${operationNameSuffix ?? ''}${ + `use${isLazy ? 'Lazy' : ''}${capitalize(exactOperationIds ? operation.operationId! : getOperationName(verb, path, operation.operationId))}${operationNameSuffix ?? ''}${ isQuery(verb, overrides) ? 'Query' : 'Mutation' }` ), undefined ); -const getReactHookName = ({ operationDefinition, endpointOverrides, config, operationNameSuffix }: GetReactHookNameParams) => { - const overrides = getOverrides(operationDefinition, endpointOverrides); +const getReactHookName = ({ + operationDefinition, + endpointOverrides, + config, + operationNameSuffix, + exactOperationIds, +}: GetReactHookNameParams) => { + const overrides = getOverrides(operationDefinition, endpointOverrides, exactOperationIds); const baseParams = { operationDefinition, overrides, operationNameSuffix, + exactOperationIds, }; const _isQuery = isQuery(operationDefinition.verb, overrides); @@ -71,6 +81,7 @@ type GenerateReactHooksParams = { endpointOverrides: EndpointOverrides[] | undefined; config: HooksConfigOptions; operationNameSuffix?: string; + exactOperationIds: boolean; }; export const generateReactHooks = ({ exportName, @@ -78,6 +89,7 @@ export const generateReactHooks = ({ endpointOverrides, config, operationNameSuffix, + exactOperationIds, }: GenerateReactHooksParams) => factory.createVariableStatement( [factory.createModifier(ts.SyntaxKind.ExportKeyword)], @@ -86,7 +98,15 @@ export const generateReactHooks = ({ factory.createVariableDeclaration( factory.createObjectBindingPattern( operationDefinitions - .map((operationDefinition) => getReactHookName({ operationDefinition, endpointOverrides, config, operationNameSuffix })) + .map((operationDefinition) => + getReactHookName({ + operationDefinition, + endpointOverrides, + config, + operationNameSuffix, + exactOperationIds, + }) + ) .flat() ), undefined, diff --git a/packages/rtk-query-codegen-openapi/src/index.ts b/packages/rtk-query-codegen-openapi/src/index.ts index 41a251d63d..d890957b1a 100644 --- a/packages/rtk-query-codegen-openapi/src/index.ts +++ b/packages/rtk-query-codegen-openapi/src/index.ts @@ -8,6 +8,8 @@ export type { ConfigFile } from './types'; const require = createRequire(__filename); +export async function generateEndpoints(options: GenerationOptions & { outputFile: string }): Promise; +export async function generateEndpoints(options: GenerationOptions & { outputFile?: never }): Promise; export async function generateEndpoints(options: GenerationOptions): Promise { const schemaLocation = options.schemaFile; diff --git a/packages/rtk-query-codegen-openapi/src/types.ts b/packages/rtk-query-codegen-openapi/src/types.ts index bdff603282..cf6db1a2b9 100644 --- a/packages/rtk-query-codegen-openapi/src/types.ts +++ b/packages/rtk-query-codegen-openapi/src/types.ts @@ -121,16 +121,25 @@ export interface CommonOptions { * @since 2.1.0 */ useUnknown?: boolean; + /** * @default false * Will generate imports with file extension matching the expected compiled output of the api file */ esmExtensions?: boolean; + /** * @default false * Will generate regex constants for pattern keywords in the schema */ outputRegexConstants?: boolean; + + /** + * @default false + * Will use the original operation IDs from the schema instead of converting them to camel case. + * May cause issues if you have duplicate/missing operation IDs + */ + exactOperationIds?: boolean; } export type TextMatcher = string | RegExp | (string | RegExp)[]; diff --git a/packages/rtk-query-codegen-openapi/test/fixtures/exactOperationId.yaml b/packages/rtk-query-codegen-openapi/test/fixtures/exactOperationId.yaml new file mode 100644 index 0000000000..38ae045d1d --- /dev/null +++ b/packages/rtk-query-codegen-openapi/test/fixtures/exactOperationId.yaml @@ -0,0 +1,375 @@ +openapi: 3.0.2 +info: + title: Swagger Petstore - OpenAPI 3.0 + version: 1.0.5 +servers: + - url: /api/v3 +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: http://swagger.io + - name: store + description: Operations about user + - name: user + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: http://swagger.io +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePETScan + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Add a new pet to the store + description: Add a new pet to the store + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '405': + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPETScansByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: false + explode: true + schema: + type: string + default: available + enum: + - available + - pending + - sold + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - write:pets + - read:pets + /user/{username}: + get: + tags: + - user + summary: Get user by user name + description: 'conflicting duplicate operation ID' + operationId: findPETScansByStatus + parameters: + - name: username + in: path + description: 'The name that needs to be fetched. Use user1 for testing. ' + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found +components: + schemas: + Order: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + example: approved + enum: + - placed + - approved + - delivered + complete: + type: boolean + xml: + name: order + Customer: + type: object + properties: + id: + type: integer + format: int64 + example: 100000 + username: + type: string + example: fehguy + address: + type: array + xml: + name: addresses + wrapped: true + items: + $ref: '#/components/schemas/Address' + xml: + name: customer + Address: + type: object + properties: + street: + type: string + example: 437 Lytton + city: + type: string + example: Palo Alto + state: + type: string + example: CA + zip: + type: string + example: '94301' + xml: + name: address + Category: + type: object + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + xml: + name: category + User: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + example: john@email.com + password: + type: string + pattern: '' + example: '12345' + phone: + type: string + pattern: '^\+?[1-9]\d{1,14}$' + example: '12345' + website: + type: string + pattern: '^https?://[^\s]+$' + example: 'https://example.com' + userStatus: + type: integer + pattern: '^[1-9]\d{0,2}$' + description: User Status + format: int32 + example: 1 + xml: + name: user + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + pattern: '^\S+$' + xml: + name: tag + Pet: + required: + - name + - photoUrls + type: object + properties: + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + category: + $ref: '#/components/schemas/Category' + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + xml: + name: '##default' + requestBodies: + Pet: + description: Pet object that needs to be added to the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + UserArray: + description: List of user object + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore3.swagger.io/oauth/authorize + scopes: + write:pets: modify pets in your account + read:pets: read your pets + api_key: + type: apiKey + name: api_key + in: header diff --git a/packages/rtk-query-codegen-openapi/test/fixtures/exactOperationIdMissingOperationId.yaml b/packages/rtk-query-codegen-openapi/test/fixtures/exactOperationIdMissingOperationId.yaml new file mode 100644 index 0000000000..3d3be4bce7 --- /dev/null +++ b/packages/rtk-query-codegen-openapi/test/fixtures/exactOperationIdMissingOperationId.yaml @@ -0,0 +1,374 @@ +openapi: 3.0.2 +info: + title: Swagger Petstore - OpenAPI 3.0 + version: 1.0.5 +servers: + - url: /api/v3 +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: http://swagger.io + - name: store + description: Operations about user + - name: user + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: http://swagger.io +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Missing operation ID - this should cause test to error when using exactOperationIds + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Add a new pet to the store + description: Add a new pet to the store + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '405': + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPETScansByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: false + explode: true + schema: + type: string + default: available + enum: + - available + - pending + - sold + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - write:pets + - read:pets + /user/{username}: + get: + tags: + - user + summary: Get user by user name + description: 'conflicting duplicate operation ID' + operationId: findPETScansByStatus + parameters: + - name: username + in: path + description: 'The name that needs to be fetched. Use user1 for testing. ' + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found +components: + schemas: + Order: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + example: approved + enum: + - placed + - approved + - delivered + complete: + type: boolean + xml: + name: order + Customer: + type: object + properties: + id: + type: integer + format: int64 + example: 100000 + username: + type: string + example: fehguy + address: + type: array + xml: + name: addresses + wrapped: true + items: + $ref: '#/components/schemas/Address' + xml: + name: customer + Address: + type: object + properties: + street: + type: string + example: 437 Lytton + city: + type: string + example: Palo Alto + state: + type: string + example: CA + zip: + type: string + example: '94301' + xml: + name: address + Category: + type: object + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + xml: + name: category + User: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + example: john@email.com + password: + type: string + pattern: '' + example: '12345' + phone: + type: string + pattern: '^\+?[1-9]\d{1,14}$' + example: '12345' + website: + type: string + pattern: '^https?://[^\s]+$' + example: 'https://example.com' + userStatus: + type: integer + pattern: '^[1-9]\d{0,2}$' + description: User Status + format: int32 + example: 1 + xml: + name: user + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + pattern: '^\S+$' + xml: + name: tag + Pet: + required: + - name + - photoUrls + type: object + properties: + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + category: + $ref: '#/components/schemas/Category' + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + xml: + name: '##default' + requestBodies: + Pet: + description: Pet object that needs to be added to the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + UserArray: + description: List of user object + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore3.swagger.io/oauth/authorize + scopes: + write:pets: modify pets in your account + read:pets: read your pets + api_key: + type: apiKey + name: api_key + in: header diff --git a/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts b/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts index 20c895219d..05f2a4b01f 100644 --- a/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts +++ b/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts @@ -933,3 +933,55 @@ describe('esmExtensions option', () => { expect(content).toContain("import { api } from '../../fixtures/emptyApi'"); }); }); + +describe('exactOperationIds option', () => { + const generateCode = (filterEndpoints: Array, schemaFile: string = 'fixtures/exactOperationId.yaml') => + generateEndpoints({ + apiFile: './fixtures/emptyApi.ts', + schemaFile: resolve(__dirname, schemaFile), + filterEndpoints, + hooks: true, + exactOperationIds: true, + }); + + test('regular camelCase operation IDs are kept the same', async () => { + const content = await generateCode(['addPet']); + expect(content).toContain('addPet: build.mutation<'); + expect(content).toContain('type AddPetApiResponse'); + expect(content).toContain('type AddPetApiArg'); + expect(content).toContain('useAddPetMutation'); + }); + + test('operations missing operation ID should not be allowed', async () => { + expect( + async () => await generateCode(['addPet'], 'fixtures/exactOperationIdMissingOperationId.yaml') + ).rejects.toThrow(new Error('exactOperationIds specified, but found operation missing operationId')); + }); + + test('duplicate operation IDs should not be allowed', async () => { + expect(async () => await generateCode(['addPet', 'findPETScansByStatus'])).rejects.toThrow( + new Error('Duplicate operation IDs not allowed when using exactOperationIds') + ); + }); + + describe('operation IDs with subsequent uppercase characters', () => { + let content: string; + + beforeAll(async () => { + content = (await generateCode(['updatePETScan', 'addPet'])) as string; + }); + + test('should not be changed in endpoint definition key', async () => { + expect(content).toContain('updatePETScan: build.mutation'); + }); + + test('should not be changed in exported hook name', async () => { + expect(content).toContain('useUpdatePETScanMutation'); + }); + + test('should not be changed in generated type names', async () => { + expect(content).toContain('UpdatePETScanApiResponse'); + expect(content).toContain('UpdatePETScanApiArg'); + }); + }); +});