Skip to content

Commit 05fad07

Browse files
aryaemami59issy
andcommitted
feat(codegen): add operationIdTransformer option
Co-authored-by: Issy Szemeti <48881813+issy@users.noreply.github.com>
1 parent affa29d commit 05fad07

File tree

7 files changed

+448
-18
lines changed

7 files changed

+448
-18
lines changed

docs/rtk-query/usage/code-generation.mdx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ interface SimpleUsage {
110110
exportName?: string
111111
argSuffix?: string
112112
operationNameSuffix?: string
113+
operationIdTransformer?: 'camelCase' | 'none' | ((operationId: string) => string)
113114
responseSuffix?: string
114115
hooks?:
115116
| boolean
@@ -147,8 +148,8 @@ export type EndpointMatcherFunction = (
147148
#### Filtering endpoints
148149
149150
If you only want to include a few endpoints, you can use the `filterEndpoints` config option to filter your endpoints.
150-
Note that endpoints are transformed to camel case. For example, `login_user` will become `loginUser`.
151-
`filterEndpoints` will be checked against this camel case version of the endpoint.
151+
Note that endpoints are transformed to camelCase by default. For example, `login_user` will become `loginUser`.
152+
`filterEndpoints` is checked against the transformed endpoint name (after applying [`operationIdTransformer`](#customizing-endpoint-name-generation)).
152153
153154
```ts no-transpile title="openapi-config.ts"
154155
const filteredConfig: ConfigFile = {
@@ -158,6 +159,48 @@ const filteredConfig: ConfigFile = {
158159
}
159160
```
160161

162+
#### Customizing endpoint name generation
163+
164+
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`.
165+
166+
Use the `operationIdTransformer` option to control this behavior:
167+
168+
- **`"camelCase"`** _(default)_ — applies lodash `camelCase`, matching prior behavior
169+
- **`"none"`** — uses the raw `operationId` string verbatim, preserving casing exactly as written in the schema
170+
- **`(operationId: string) => string`** — applies a custom function for full control
171+
172+
```ts no-transpile title="openapi-config.ts"
173+
import type { ConfigFile } from '@rtk-query/codegen-openapi'
174+
175+
const config = {
176+
// ...
177+
// Preserve the exact operationId casing from the schema.
178+
// fetchMyJWTPlease stays fetchMyJWTPlease instead of becoming fetchMyJwtPlease.
179+
operationIdTransformer: 'none',
180+
} satisfies ConfigFile
181+
182+
export default config
183+
```
184+
185+
```ts no-transpile title="openapi-config.ts"
186+
import type { ConfigFile } from '@rtk-query/codegen-openapi'
187+
188+
const config = {
189+
// ...
190+
// Custom transformer — capitalize the first letter only
191+
operationIdTransformer: (operationId) =>
192+
operationId.charAt(0).toUpperCase() + operationId.slice(1),
193+
} satisfies ConfigFile
194+
195+
export default config
196+
```
197+
198+
:::note
199+
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.
200+
201+
When using `filterEndpoints` together with `operationIdTransformer`, the filter is matched against the **transformed** name.
202+
:::
203+
161204
#### Endpoint overrides
162205

163206
If an endpoint is generated as a mutation instead of a query or the other way round, you can override that:

packages/rtk-query-codegen-openapi/src/generate.ts

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
EndpointOverrides,
2121
GenerationOptions,
2222
OperationDefinition,
23+
OperationIdTransformer,
2324
ParameterDefinition,
2425
ParameterMatcher,
2526
TextMatcher,
@@ -38,8 +39,47 @@ function defaultIsDataResponse(code: string, includeDefault: boolean) {
3839
return !Number.isNaN(parsedCode) && parsedCode >= 200 && parsedCode < 300;
3940
}
4041

41-
function getOperationName({ verb, path, operation }: Pick<OperationDefinition, 'verb' | 'path' | 'operation'>) {
42-
return _getOperationName(verb, path, operation.operationId);
42+
/**
43+
* Resolves the generated endpoint name for an operation by applying the
44+
* configured {@linkcode OperationIdTransformer}.
45+
*
46+
* - `"camelCase"` *(default)* — delegates to `oazapfts` `getOperationName`,
47+
* which applies lodash `camelCase` and falls back to a verb+path derived
48+
* name when `operationId` is absent.
49+
* - `"none"` — returns `operation.operationId` verbatim.
50+
* - `(operationId: string) => string` — calls the provided function with `operation.operationId`.
51+
*
52+
* For `"none"` and function transformers, a missing `operationId` throws an
53+
* {@linkcode Error} with the offending HTTP method and path in the message.
54+
*
55+
* @param operationDefinition - The operation to resolve a name for.
56+
* @param operationIdTransformer - How to transform the `operationId`.
57+
* @returns The resolved endpoint name string.
58+
* @throws An {@linkcode Error} When `operationId` is `undefined` and transformer is not `"camelCase"`.
59+
*
60+
* @since 2.3.0
61+
*/
62+
export function resolveOperationName(
63+
operationDefinition: Pick<OperationDefinition, 'verb' | 'path' | 'operation'>,
64+
operationIdTransformer: OperationIdTransformer = 'camelCase'
65+
): string {
66+
const { verb, path, operation } = operationDefinition;
67+
68+
if (operationIdTransformer === 'camelCase') {
69+
return _getOperationName(verb, path, operation.operationId);
70+
}
71+
72+
if (operation.operationId === undefined) {
73+
throw new Error(
74+
`operationIdTransformer: "${typeof operationIdTransformer === 'function' ? 'function' : operationIdTransformer}" requires all operations to have an operationId, but found a missing operationId at ${verb.toUpperCase()} ${path}`
75+
);
76+
}
77+
78+
if (operationIdTransformer === 'none') {
79+
return operation.operationId;
80+
}
81+
82+
return operationIdTransformer(operation.operationId);
4383
}
4484

4585
function getTags({ verb, pathItem }: Pick<OperationDefinition, 'verb' | 'pathItem'>): string[] {
@@ -56,11 +96,11 @@ function patternMatches(pattern?: TextMatcher) {
5696
};
5797
}
5898

59-
function operationMatches(pattern?: EndpointMatcher) {
99+
function operationMatches(pattern?: EndpointMatcher, operationIdTransformer: OperationIdTransformer = 'camelCase') {
60100
const checkMatch = typeof pattern === 'function' ? pattern : patternMatches(pattern);
61101
return function matcher(operationDefinition: OperationDefinition) {
62102
if (!pattern) return true;
63-
const operationName = getOperationName(operationDefinition);
103+
const operationName = resolveOperationName(operationDefinition, operationIdTransformer);
64104
return checkMatch(operationName, operationDefinition);
65105
};
66106
}
@@ -139,9 +179,10 @@ function generateRegexConstantsForType(
139179

140180
export function getOverrides(
141181
operation: OperationDefinition,
142-
endpointOverrides?: EndpointOverrides[]
182+
endpointOverrides?: EndpointOverrides[],
183+
operationIdTransformer: OperationIdTransformer = 'camelCase'
143184
): EndpointOverrides | undefined {
144-
return endpointOverrides?.find((override) => operationMatches(override.pattern)(operation));
185+
return endpointOverrides?.find((override) => operationMatches(override.pattern, operationIdTransformer)(operation));
145186
}
146187

147188
export async function generateApi(
@@ -170,6 +211,7 @@ export async function generateApi(
170211
useUnknown = false,
171212
esmExtensions = false,
172213
outputRegexConstants = false,
214+
operationIdTransformer = 'camelCase',
173215
}: GenerationOptions
174216
) {
175217
const v3Doc = (v3DocCache[spec] ??= await getV3Doc(spec, httpResolverOptions));
@@ -186,7 +228,9 @@ export async function generateApi(
186228
apiGen.preprocessComponents(apiGen.spec.components.schemas);
187229
}
188230

189-
const operationDefinitions = getOperationDefinitions(v3Doc).filter(operationMatches(filterEndpoints));
231+
const operationDefinitions = getOperationDefinitions(v3Doc).filter(
232+
operationMatches(filterEndpoints, operationIdTransformer)
233+
);
190234

191235
const resultFile = ts.createSourceFile(
192236
'someFileName.ts',
@@ -239,7 +283,7 @@ export async function generateApi(
239283
operationDefinitions.map((operationDefinition) =>
240284
generateEndpoint({
241285
operationDefinition,
242-
overrides: getOverrides(operationDefinition, endpointOverrides),
286+
overrides: getOverrides(operationDefinition, endpointOverrides, operationIdTransformer),
243287
})
244288
),
245289
true
@@ -278,6 +322,7 @@ export async function generateApi(
278322
endpointOverrides,
279323
config: hooks,
280324
operationNameSuffix,
325+
operationIdTransformer,
281326
}),
282327
]
283328
: []),
@@ -314,7 +359,7 @@ export async function generateApi(
314359
operation,
315360
operation: { responses, requestBody },
316361
} = operationDefinition;
317-
const operationName = getOperationName({ verb, path, operation });
362+
const operationName = resolveOperationName({ verb, path, operation }, operationIdTransformer);
318363
const tags = tag ? getTags({ verb, pathItem }) : undefined;
319364
const isQuery = testIsQuery(verb, overrides);
320365

packages/rtk-query-codegen-openapi/src/generators/react-hooks.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import ts from 'typescript';
2-
import { getOperationName } from 'oazapfts/generate';
2+
import { getOverrides, resolveOperationName } from '../generate';
3+
import type { ConfigFile, EndpointOverrides, OperationDefinition, OperationIdTransformer } from '../types';
34
import { capitalize, isQuery } from '../utils';
4-
import type { OperationDefinition, EndpointOverrides, ConfigFile } from '../types';
5-
import { getOverrides } from '../generate';
65
import { factory } from '../utils/factory';
76

87
type HooksConfigOptions = NonNullable<ConfigFile['hooks']>;
@@ -12,39 +11,49 @@ type GetReactHookNameParams = {
1211
endpointOverrides: EndpointOverrides[] | undefined;
1312
config: HooksConfigOptions;
1413
operationNameSuffix?: string;
14+
operationIdTransformer?: OperationIdTransformer;
1515
};
1616

1717
type CreateBindingParams = {
1818
operationDefinition: OperationDefinition;
1919
overrides?: EndpointOverrides;
2020
isLazy?: boolean;
2121
operationNameSuffix?: string;
22+
operationIdTransformer?: OperationIdTransformer;
2223
};
2324

2425
const createBinding = ({
2526
operationDefinition: { verb, path, operation },
2627
overrides,
2728
isLazy = false,
2829
operationNameSuffix,
30+
operationIdTransformer,
2931
}: CreateBindingParams) =>
3032
factory.createBindingElement(
3133
undefined,
3234
undefined,
3335
factory.createIdentifier(
34-
`use${isLazy ? 'Lazy' : ''}${capitalize(getOperationName(verb, path, operation.operationId))}${operationNameSuffix ?? ''}${
36+
`use${isLazy ? 'Lazy' : ''}${capitalize(resolveOperationName({ verb, path, operation }, operationIdTransformer))}${operationNameSuffix ?? ''}${
3537
isQuery(verb, overrides) ? 'Query' : 'Mutation'
3638
}`
3739
),
3840
undefined
3941
);
4042

41-
const getReactHookName = ({ operationDefinition, endpointOverrides, config, operationNameSuffix }: GetReactHookNameParams) => {
42-
const overrides = getOverrides(operationDefinition, endpointOverrides);
43+
const getReactHookName = ({
44+
operationDefinition,
45+
endpointOverrides,
46+
config,
47+
operationNameSuffix,
48+
operationIdTransformer,
49+
}: GetReactHookNameParams) => {
50+
const overrides = getOverrides(operationDefinition, endpointOverrides, operationIdTransformer);
4351

4452
const baseParams = {
4553
operationDefinition,
4654
overrides,
4755
operationNameSuffix,
56+
operationIdTransformer,
4857
};
4958

5059
const _isQuery = isQuery(operationDefinition.verb, overrides);
@@ -71,13 +80,15 @@ type GenerateReactHooksParams = {
7180
endpointOverrides: EndpointOverrides[] | undefined;
7281
config: HooksConfigOptions;
7382
operationNameSuffix?: string;
83+
operationIdTransformer?: OperationIdTransformer;
7484
};
7585
export const generateReactHooks = ({
7686
exportName,
7787
operationDefinitions,
7888
endpointOverrides,
7989
config,
8090
operationNameSuffix,
91+
operationIdTransformer,
8192
}: GenerateReactHooksParams) =>
8293
factory.createVariableStatement(
8394
[factory.createModifier(ts.SyntaxKind.ExportKeyword)],
@@ -86,7 +97,15 @@ export const generateReactHooks = ({
8697
factory.createVariableDeclaration(
8798
factory.createObjectBindingPattern(
8899
operationDefinitions
89-
.map((operationDefinition) => getReactHookName({ operationDefinition, endpointOverrides, config, operationNameSuffix }))
100+
.map((operationDefinition) =>
101+
getReactHookName({
102+
operationDefinition,
103+
endpointOverrides,
104+
config,
105+
operationNameSuffix,
106+
operationIdTransformer,
107+
})
108+
)
90109
.flat()
91110
),
92111
undefined,

packages/rtk-query-codegen-openapi/src/types.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ export interface CommonOptions {
5757
* @default ""
5858
*/
5959
operationNameSuffix?: string;
60+
/**
61+
* Controls how OpenAPI **`operationId`** values are transformed into
62+
* endpoint names.
63+
* @see {@linkcode OperationIdTransformer} for details.
64+
*
65+
* @default "camelCase"
66+
* @since 2.3.0
67+
*/
68+
operationIdTransformer?: OperationIdTransformer;
6069
/**
6170
* `true` will generate hooks for queries and mutations, but no lazyQueries
6271
* @default false
@@ -133,6 +142,37 @@ export interface CommonOptions {
133142
outputRegexConstants?: boolean;
134143
}
135144

145+
/**
146+
* Controls how OpenAPI **`operationId`** values are transformed
147+
* into endpoint names.
148+
*
149+
* - **`"camelCase"`** *(default)* — applies lodash **`camelCase`** via **`oazapfts`** (current behavior)
150+
* - **`"none"`** — uses the raw **`operationId`** string verbatim with no transformation
151+
* - **`(operationId: string) => string`** — applies a custom function to each **`operationId`**
152+
*
153+
* When using **`"none"`** or a custom function every operation **must**
154+
* have an **`operationId`** defined in the OpenAPI schema, otherwise
155+
* an {@linkcode Error | Error} is thrown during generation.
156+
*
157+
* @example
158+
* <caption>Preserve exact casing (e.g. `fetchMyJWTPlease` stays `fetchMyJWTPlease`)</caption>
159+
*
160+
* ```ts
161+
* operationIdTransformer: 'none'
162+
* ```
163+
*
164+
* @example
165+
* <caption>Custom transformer</caption>
166+
*
167+
* ```ts
168+
* operationIdTransformer: (id) => id.replace(/^get/, 'fetch')
169+
* ```
170+
*
171+
* @since 2.3.0
172+
* @public
173+
*/
174+
export type OperationIdTransformer = 'camelCase' | 'none' | ((operationId: string) => string);
175+
136176
export type TextMatcher = string | RegExp | (string | RegExp)[];
137177

138178
export type EndpointMatcherFunction = (operationName: string, operationDefinition: OperationDefinition) => boolean;

0 commit comments

Comments
 (0)