From e4946132d1ffd010675161fbff950b740b50a019 Mon Sep 17 00:00:00 2001 From: Arya Emami Date: Wed, 25 Feb 2026 11:36:42 -0600 Subject: [PATCH] feat(codegen): add `operationIdTransformer` option Co-authored-by: Issy Szemeti <48881813+issy@users.noreply.github.com> --- docs/rtk-query/usage/code-generation.mdx | 47 ++++- .../rtk-query-codegen-openapi/src/generate.ts | 69 ++++++- .../src/generators/react-hooks.ts | 33 ++- .../rtk-query-codegen-openapi/src/types.ts | 41 ++++ .../test/fixtures/operationIdTransformer.yaml | 73 +++++++ .../operationIdTransformerMissingId.yaml | 22 ++ .../test/generateEndpoints.test.ts | 188 ++++++++++++++++++ 7 files changed, 454 insertions(+), 19 deletions(-) create mode 100644 packages/rtk-query-codegen-openapi/test/fixtures/operationIdTransformer.yaml create mode 100644 packages/rtk-query-codegen-openapi/test/fixtures/operationIdTransformerMissingId.yaml diff --git a/docs/rtk-query/usage/code-generation.mdx b/docs/rtk-query/usage/code-generation.mdx index f307339267..cbb6ca7451 100644 --- a/docs/rtk-query/usage/code-generation.mdx +++ b/docs/rtk-query/usage/code-generation.mdx @@ -110,6 +110,7 @@ interface SimpleUsage { exportName?: string argSuffix?: string operationNameSuffix?: string + operationIdTransformer?: 'camelCase' | 'none' | ((operationId: string) => string) responseSuffix?: string hooks?: | boolean @@ -147,8 +148,8 @@ export type EndpointMatcherFunction = ( #### Filtering endpoints If you only want to include a few endpoints, you can use the `filterEndpoints` config option to filter your endpoints. -Note that endpoints are transformed to camel case. For example, `login_user` will become `loginUser`. -`filterEndpoints` will be checked against this camel case version of the endpoint. +Note that endpoints are transformed to camelCase by default. For example, `login_user` will become `loginUser`. +`filterEndpoints` is checked against the transformed endpoint name (after applying [`operationIdTransformer`](#customizing-endpoint-name-generation)). ```ts no-transpile title="openapi-config.ts" const filteredConfig: ConfigFile = { @@ -158,6 +159,48 @@ const filteredConfig: ConfigFile = { } ``` +#### Customizing endpoint name generation + +By default, each operation's `operationId` is converted to camelCase using lodash `camelCase` (via `oazapfts`). This means consecutive uppercase letters are lowercased — for example, `fetchMyJWTPlease` becomes `fetchMyJwtPlease`. + +Use the `operationIdTransformer` option to control this behavior: + +- **`"camelCase"`** _(default)_ - applies lodash `camelCase`, matching prior behavior +- **`"none"`** - uses the raw `operationId` string verbatim, preserving casing exactly as written in the schema +- **`(operationId: string) => string`** - applies a custom function for full control + +```ts no-transpile title="openapi-config.ts" +import type { ConfigFile } from '@rtk-query/codegen-openapi' + +const config = { + // ... + // Preserve the exact operationId casing from the schema. + // fetchMyJWTPlease stays fetchMyJWTPlease instead of becoming fetchMyJwtPlease. + operationIdTransformer: 'none', +} satisfies ConfigFile + +export default config +``` + +```ts no-transpile title="openapi-config.ts" +import type { ConfigFile } from '@rtk-query/codegen-openapi' + +const config = { + // ... + // Custom transformer - capitalize the first letter only + operationIdTransformer: (operationId) => + operationId.charAt(0).toUpperCase() + operationId.slice(1), +} satisfies ConfigFile + +export default config +``` + +:::note +When `operationIdTransformer` is `"none"` or a custom function, **every operation in the schema must have an `operationId`**. The codegen will throw an error if any operation is missing one. + +When using `filterEndpoints` together with `operationIdTransformer`, the filter is matched against the **transformed** name. +::: + #### 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 220b163d7c..8c9f1c84b6 100644 --- a/packages/rtk-query-codegen-openapi/src/generate.ts +++ b/packages/rtk-query-codegen-openapi/src/generate.ts @@ -5,7 +5,7 @@ import { UNSTABLE_cg as cg } from 'oazapfts'; import type { OazapftsContext } from 'oazapfts/context'; import { createContext, withMode } from 'oazapfts/context'; import { - getOperationName as _getOperationName, + getOperationName, getResponseType, getSchemaFromContent, getTypeFromResponse, @@ -22,6 +22,7 @@ import type { EndpointOverrides, GenerationOptions, OperationDefinition, + OperationIdTransformer, ParameterDefinition, ParameterMatcher, TextMatcher, @@ -70,8 +71,51 @@ function defaultIsDataResponse(code: string, includeDefault: boolean) { return !Number.isNaN(parsedCode) && parsedCode >= 200 && parsedCode < 300; } -function getOperationName({ verb, path, operation }: Pick) { - return _getOperationName(verb, path, operation.operationId); +/** + * Resolves the generated endpoint name for an operation by applying the + * configured {@linkcode OperationIdTransformer}. + * + * - `"camelCase"` *(default)* - delegates to `oazapfts` + * {@linkcode getOperationName | getOperationName()}, which applies lodash + * {@linkcode camelCase | camelCase()} and falls back to a verb+path derived + * name when {@linkcode operation.operationId} is absent. + * - `"none"` - returns {@linkcode operation.operationId} verbatim. + * - `(operationId: string) => string` - calls the provided function with + * {@linkcode operation.operationId}. + * + * For `"none"` and function transformers, a missing + * {@linkcode operation.operationId} throws an {@linkcode Error} with the + * offending HTTP method and path in the message. + * + * @param operationDefinition - The operation to resolve a name for. + * @param [operationIdTransformer] - How to transform the {@linkcode operation.operationId | operationId}. + * @returns The resolved endpoint name string. + * @throws An {@linkcode Error} when {@linkcode operation.operationId | operationId} is `undefined` and transformer is not `"camelCase"`. + * + * @since 2.3.0 + * @public + */ +export function resolveOperationName( + operationDefinition: Pick, + operationIdTransformer: OperationIdTransformer = 'camelCase' +): string { + const { verb, path, operation } = operationDefinition; + + if (operationIdTransformer === 'camelCase') { + return getOperationName(verb, path, operation.operationId); + } + + if (operation.operationId === undefined) { + throw new Error( + `operationIdTransformer: "${typeof operationIdTransformer === 'function' ? 'function' : operationIdTransformer}" requires all operations to have an operationId, but found a missing operationId at ${verb.toUpperCase()} ${path}` + ); + } + + if (operationIdTransformer === 'none') { + return operation.operationId; + } + + return operationIdTransformer(operation.operationId); } function getTags({ verb, pathItem }: Pick): string[] { @@ -88,11 +132,11 @@ function patternMatches(pattern?: TextMatcher) { }; } -function operationMatches(pattern?: EndpointMatcher) { +function operationMatches(pattern?: EndpointMatcher, operationIdTransformer: OperationIdTransformer = 'camelCase') { const checkMatch = typeof pattern === 'function' ? pattern : patternMatches(pattern); return function matcher(operationDefinition: OperationDefinition) { if (!pattern) return true; - const operationName = getOperationName(operationDefinition); + const operationName = resolveOperationName(operationDefinition, operationIdTransformer); return checkMatch(operationName, operationDefinition); }; } @@ -171,9 +215,10 @@ function generateRegexConstantsForType( export function getOverrides( operation: OperationDefinition, - endpointOverrides?: EndpointOverrides[] + endpointOverrides?: EndpointOverrides[], + operationIdTransformer: OperationIdTransformer = 'camelCase' ): EndpointOverrides | undefined { - return endpointOverrides?.find((override) => operationMatches(override.pattern)(operation)); + return endpointOverrides?.find((override) => operationMatches(override.pattern, operationIdTransformer)(operation)); } export async function generateApi( @@ -202,6 +247,7 @@ export async function generateApi( useUnknown = false, esmExtensions = false, outputRegexConstants = false, + operationIdTransformer = 'camelCase', }: GenerationOptions ) { const v3Doc = (v3DocCache[spec] ??= await getV3Doc(spec, httpResolverOptions)); @@ -214,7 +260,9 @@ export async function generateApi( }); preprocessComponents(ctx); - const operationDefinitions = getOperationDefinitions(v3Doc).filter(operationMatches(filterEndpoints)); + const operationDefinitions = getOperationDefinitions(v3Doc).filter( + operationMatches(filterEndpoints, operationIdTransformer) + ); const resultFile = ts.createSourceFile( 'someFileName.ts', @@ -267,7 +315,7 @@ export async function generateApi( operationDefinitions.map((operationDefinition) => generateEndpoint({ operationDefinition, - overrides: getOverrides(operationDefinition, endpointOverrides), + overrides: getOverrides(operationDefinition, endpointOverrides, operationIdTransformer), }) ), true @@ -306,6 +354,7 @@ export async function generateApi( endpointOverrides, config: hooks, operationNameSuffix, + operationIdTransformer, }), ] : []), @@ -342,7 +391,7 @@ export async function generateApi( operation, operation: { responses, requestBody }, } = operationDefinition; - const operationName = getOperationName({ verb, path, operation }); + const operationName = resolveOperationName({ verb, path, operation }, operationIdTransformer); 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..c70c8f1f7e 100644 --- a/packages/rtk-query-codegen-openapi/src/generators/react-hooks.ts +++ b/packages/rtk-query-codegen-openapi/src/generators/react-hooks.ts @@ -1,8 +1,7 @@ import ts from 'typescript'; -import { getOperationName } from 'oazapfts/generate'; +import { getOverrides, resolveOperationName } from '../generate'; +import type { ConfigFile, EndpointOverrides, OperationDefinition, OperationIdTransformer } from '../types'; import { capitalize, isQuery } from '../utils'; -import type { OperationDefinition, EndpointOverrides, ConfigFile } from '../types'; -import { getOverrides } from '../generate'; import { factory } from '../utils/factory'; type HooksConfigOptions = NonNullable; @@ -12,6 +11,7 @@ type GetReactHookNameParams = { endpointOverrides: EndpointOverrides[] | undefined; config: HooksConfigOptions; operationNameSuffix?: string; + operationIdTransformer?: OperationIdTransformer; }; type CreateBindingParams = { @@ -19,6 +19,7 @@ type CreateBindingParams = { overrides?: EndpointOverrides; isLazy?: boolean; operationNameSuffix?: string; + operationIdTransformer?: OperationIdTransformer; }; const createBinding = ({ @@ -26,25 +27,33 @@ const createBinding = ({ overrides, isLazy = false, operationNameSuffix, + operationIdTransformer, }: CreateBindingParams) => factory.createBindingElement( undefined, undefined, factory.createIdentifier( - `use${isLazy ? 'Lazy' : ''}${capitalize(getOperationName(verb, path, operation.operationId))}${operationNameSuffix ?? ''}${ + `use${isLazy ? 'Lazy' : ''}${capitalize(resolveOperationName({ verb, path, operation }, operationIdTransformer))}${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, + operationIdTransformer, +}: GetReactHookNameParams) => { + const overrides = getOverrides(operationDefinition, endpointOverrides, operationIdTransformer); const baseParams = { operationDefinition, overrides, operationNameSuffix, + operationIdTransformer, }; const _isQuery = isQuery(operationDefinition.verb, overrides); @@ -71,6 +80,7 @@ type GenerateReactHooksParams = { endpointOverrides: EndpointOverrides[] | undefined; config: HooksConfigOptions; operationNameSuffix?: string; + operationIdTransformer?: OperationIdTransformer; }; export const generateReactHooks = ({ exportName, @@ -78,6 +88,7 @@ export const generateReactHooks = ({ endpointOverrides, config, operationNameSuffix, + operationIdTransformer, }: GenerateReactHooksParams) => factory.createVariableStatement( [factory.createModifier(ts.SyntaxKind.ExportKeyword)], @@ -86,7 +97,15 @@ export const generateReactHooks = ({ factory.createVariableDeclaration( factory.createObjectBindingPattern( operationDefinitions - .map((operationDefinition) => getReactHookName({ operationDefinition, endpointOverrides, config, operationNameSuffix })) + .map((operationDefinition) => + getReactHookName({ + operationDefinition, + endpointOverrides, + config, + operationNameSuffix, + operationIdTransformer, + }) + ) .flat() ), undefined, diff --git a/packages/rtk-query-codegen-openapi/src/types.ts b/packages/rtk-query-codegen-openapi/src/types.ts index bdff603282..54a06c57ae 100644 --- a/packages/rtk-query-codegen-openapi/src/types.ts +++ b/packages/rtk-query-codegen-openapi/src/types.ts @@ -57,6 +57,16 @@ export interface CommonOptions { * @default "" */ operationNameSuffix?: string; + + /** + * Controls how OpenAPI **`operationId`** values are transformed into + * endpoint names. + * @see {@linkcode OperationIdTransformer} for details. + * + * @default "camelCase" + * @since 2.3.0 + */ + operationIdTransformer?: OperationIdTransformer; /** * `true` will generate hooks for queries and mutations, but no lazyQueries * @default false @@ -133,6 +143,37 @@ export interface CommonOptions { outputRegexConstants?: boolean; } +/** + * Controls how OpenAPI **`operationId`** values are transformed into + * endpoint names. + * + * - **`"camelCase"`** *(default)* - applies lodash **`camelCase`** via **`oazapfts`** (current behavior) + * - **`"none"`** - uses the raw **`operationId`** string verbatim with no transformation + * - **`(operationId: string) => string`** - applies a custom function to each **`operationId`** + * + * When using **`"none"`** or a custom function every operation **must** + * have an **`operationId`** defined in the OpenAPI schema, otherwise + * an {@linkcode Error} is thrown during generation. + * + * @example + * Preserve exact casing (e.g. `fetchMyJWTPlease` stays `fetchMyJWTPlease`) + * + * ```ts + * operationIdTransformer: 'none' + * ``` + * + * @example + * Custom transformer + * + * ```ts + * operationIdTransformer: (id) => id.replace(/^get/, 'fetch') + * ``` + * + * @since 2.3.0 + * @public + */ +export type OperationIdTransformer = 'camelCase' | 'none' | ((operationId: string) => string); + export type TextMatcher = string | RegExp | (string | RegExp)[]; export type EndpointMatcherFunction = (operationName: string, operationDefinition: OperationDefinition) => boolean; diff --git a/packages/rtk-query-codegen-openapi/test/fixtures/operationIdTransformer.yaml b/packages/rtk-query-codegen-openapi/test/fixtures/operationIdTransformer.yaml new file mode 100644 index 0000000000..235ec89d72 --- /dev/null +++ b/packages/rtk-query-codegen-openapi/test/fixtures/operationIdTransformer.yaml @@ -0,0 +1,73 @@ +openapi: 3.0.2 +info: + title: operationIdTransformer fixture + description: > + Fixture for testing the `operationIdTransformer` option. + Includes operationIds with consecutive uppercase letters + (e.g. `fetchMyJWTPlease`, `updatePETScan`) to verify that + transformers like `"none"` preserve them verbatim while the + default `"camelCase"` normalizes them to `fetchMyJwtPlease` + and `updatePetScan`. + version: 1.0.0 +paths: + /pet: + post: + operationId: addPet + summary: Add a new pet + description: Regular camelCase operationId - should be unaffected by any transformer. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Pet added successfully + /jwt: + get: + operationId: fetchMyJWTPlease + summary: Fetch a JWT token + description: > + operationId contains consecutive uppercase letters (`JWT`). + With `"camelCase"` this becomes `fetchMyJwtPlease`; + with `"none"` it stays `fetchMyJWTPlease`. + responses: + '200': + description: JWT token string + content: + application/json: + schema: + type: string + put: + operationId: updateMyJWTPlease + summary: Replace a JWT token + description: > + operationId contains consecutive uppercase letters (`JWT`). + Used to verify that mutations are also renamed consistently. + requestBody: + required: true + content: + application/json: + schema: + type: string + responses: + '200': + description: JWT token updated successfully + /scan: + put: + operationId: updatePETScan + summary: Update a PET scan record + description: > + operationId contains a different consecutive-uppercase sequence (`PET`). + Used alongside `fetchMyJWTPlease` to confirm the transformer is applied + uniformly across all operations. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: PET scan record updated successfully diff --git a/packages/rtk-query-codegen-openapi/test/fixtures/operationIdTransformerMissingId.yaml b/packages/rtk-query-codegen-openapi/test/fixtures/operationIdTransformerMissingId.yaml new file mode 100644 index 0000000000..7357018e91 --- /dev/null +++ b/packages/rtk-query-codegen-openapi/test/fixtures/operationIdTransformerMissingId.yaml @@ -0,0 +1,22 @@ +openapi: 3.0.2 +info: + title: operationIdTransformer missing operationId fixture + description: > + Fixture for testing that `operationIdTransformer: "none"` and custom + transformer functions throw an informative error when an operation is + missing its `operationId`. The single `GET /pet` endpoint intentionally + omits `operationId` to trigger this error path. The `"camelCase"` default + should handle this gracefully by deriving a name from the HTTP verb and + path instead. + version: 1.0.0 +paths: + /pet: + get: + # intentionally no operationId + responses: + '200': + description: Pet retrieved successfully + content: + application/json: + schema: + type: object diff --git a/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts b/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts index d08c5e48bf..3da159d36f 100644 --- a/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts +++ b/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts @@ -948,6 +948,194 @@ describe('esmExtensions option', () => { }); }); +describe('operationIdTransformer option', () => { + const schemaFile = resolve(__dirname, 'fixtures', 'operationIdTransformer.yaml'); + const missingIdSchemaFile = resolve(__dirname, 'fixtures', 'operationIdTransformerMissingId.yaml'); + + const baseConfig = { + apiFile: './fixtures/emptyApi.ts', + hooks: true, + schemaFile, + }; + + const noneConfig = { + ...baseConfig, + operationIdTransformer: 'none' as const, + }; + + const prefixTransformer = (operationId: OperationIdType) => + `custom_${operationId}` as const; + + describe('"camelCase" (default)', () => { + test('transforms consecutive uppercase letters', async () => { + const api = await generateEndpoints({ + ...baseConfig, + filterEndpoints: ['fetchMyJwtPlease'], + }); + + expect.assert.isOk(api); + + expect(api).toContain('fetchMyJwtPlease:'); + expect(api).toContain('type FetchMyJwtPleaseApiResponse'); + expect(api).toContain('type FetchMyJwtPleaseApiArg'); + expect(api).toContain('useFetchMyJwtPleaseQuery'); + }); + + test('does not change already-correct camelCase operationIds', async () => { + const api = await generateEndpoints({ + ...baseConfig, + filterEndpoints: ['addPet'], + }); + + expect.assert.isOk(api); + + expect(api).toContain('addPet:'); + expect(api).toContain('type AddPetApiResponse'); + expect(api).toContain('type AddPetApiArg'); + expect(api).toContain('useAddPetMutation'); + }); + + test('does not throw when operationId is missing (backward compat)', async () => { + await expect( + generateEndpoints({ + apiFile: './fixtures/emptyApi.ts', + schemaFile: missingIdSchemaFile, + }) + ).resolves.toBeTruthy(); + }); + }); + + describe('"none"', () => { + test('preserves consecutive uppercase letters in endpoint key', async () => { + const api = await generateEndpoints({ + ...noneConfig, + filterEndpoints: ['fetchMyJWTPlease'], + }); + + expect.assert.isOk(api); + + expect(api).toContain('fetchMyJWTPlease:'); + }); + + test('preserves consecutive uppercase letters in type names', async () => { + const api = await generateEndpoints({ + ...noneConfig, + filterEndpoints: ['fetchMyJWTPlease'], + }); + + expect.assert.isOk(api); + + expect(api).toContain('type FetchMyJWTPleaseApiResponse'); + expect(api).toContain('type FetchMyJWTPleaseApiArg'); + }); + + test('preserves consecutive uppercase letters in hook name', async () => { + const api = await generateEndpoints({ + ...noneConfig, + filterEndpoints: ['fetchMyJWTPlease'], + }); + + expect.assert.isOk(api); + + expect(api).toContain('useFetchMyJWTPleaseQuery'); + }); + + test('does not change already-correct camelCase operationIds', async () => { + const api = await generateEndpoints({ + ...noneConfig, + filterEndpoints: ['addPet'], + }); + + expect.assert.isOk(api); + + expect(api).toContain('addPet:'); + expect(api).toContain('useAddPetMutation'); + }); + + test('filterEndpoints matches against the raw operationId', async () => { + // 'fetchMyJWTPlease' should NOT match when filtering for the camelCased form + const apiNoMatch = await generateEndpoints({ + ...noneConfig, + filterEndpoints: ['fetchMyJwtPlease'], + }); + + expect.assert.isOk(apiNoMatch); + + expect(apiNoMatch).not.toContain('fetchMyJWTPlease:'); + + // 'fetchMyJWTPlease' SHOULD match when filtering for the exact operationId + const apiMatch = await generateEndpoints({ + ...noneConfig, + filterEndpoints: ['fetchMyJWTPlease'], + }); + + expect.assert.isOk(apiMatch); + + expect(apiMatch).toContain('fetchMyJWTPlease:'); + }); + + test('throws when an operation is missing an operationId', async () => { + await expect( + generateEndpoints({ + apiFile: './fixtures/emptyApi.ts', + operationIdTransformer: 'none', + schemaFile: missingIdSchemaFile, + }) + ).rejects.toThrow(/operationIdTransformer.*none.*missing operationId.*GET.*\/pet/i); + }); + }); + + describe('custom function transformer', () => { + test('applies the function to each operationId', async () => { + const api = await generateEndpoints({ + ...baseConfig, + filterEndpoints: ['custom_fetchMyJWTPlease', 'custom_addPet'], + operationIdTransformer: prefixTransformer, + }); + + expect.assert.isOk(api); + + expect(api).toContain('custom_fetchMyJWTPlease:'); + expect(api).toContain('custom_addPet:'); + }); + + test('uses the transformed name in type aliases', async () => { + const api = await generateEndpoints({ + ...baseConfig, + filterEndpoints: ['custom_fetchMyJWTPlease'], + operationIdTransformer: prefixTransformer, + }); + + expect.assert.isOk(api); + + expect(api).toContain('type Custom_fetchMyJWTPleaseApiResponse'); + expect(api).toContain('type Custom_fetchMyJWTPleaseApiArg'); + }); + + test('uses the transformed name in hook names', async () => { + const api = await generateEndpoints({ + ...baseConfig, + filterEndpoints: ['custom_fetchMyJWTPlease'], + operationIdTransformer: prefixTransformer, + }); + + expect.assert.isOk(api); + + expect(api).toContain('useCustom_fetchMyJWTPleaseQuery'); + }); + + test('throws when an operation is missing an operationId', async () => { + await expect( + generateEndpoints({ + apiFile: './fixtures/emptyApi.ts', + operationIdTransformer: (operationId) => operationId, + schemaFile: missingIdSchemaFile, + }) + ).rejects.toThrow(/operationIdTransformer.*function.*missing operationId.*GET.*\/pet/i); + }); + }); +}); + describe('generateEndpoints return type narrowing', () => { const schemaFile = resolve(__dirname, 'fixtures', 'petstore.json');