Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions docs/rtk-query/usage/code-generation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ interface SimpleUsage {
exportName?: string
argSuffix?: string
operationNameSuffix?: string
operationIdTransformer?: 'camelCase' | 'none' | ((operationId: string) => string)
responseSuffix?: string
hooks?:
| boolean
Expand Down Expand Up @@ -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 = {
Expand All @@ -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:
Expand Down
69 changes: 59 additions & 10 deletions packages/rtk-query-codegen-openapi/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +22,7 @@ import type {
EndpointOverrides,
GenerationOptions,
OperationDefinition,
OperationIdTransformer,
ParameterDefinition,
ParameterMatcher,
TextMatcher,
Expand Down Expand Up @@ -70,8 +71,51 @@ function defaultIsDataResponse(code: string, includeDefault: boolean) {
return !Number.isNaN(parsedCode) && parsedCode >= 200 && parsedCode < 300;
}

function getOperationName({ verb, path, operation }: Pick<OperationDefinition, 'verb' | 'path' | 'operation'>) {
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<OperationDefinition, 'verb' | 'path' | 'operation'>,
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<OperationDefinition, 'verb' | 'pathItem'>): string[] {
Expand All @@ -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);
};
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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));
Expand All @@ -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',
Expand Down Expand Up @@ -267,7 +315,7 @@ export async function generateApi(
operationDefinitions.map((operationDefinition) =>
generateEndpoint({
operationDefinition,
overrides: getOverrides(operationDefinition, endpointOverrides),
overrides: getOverrides(operationDefinition, endpointOverrides, operationIdTransformer),
})
),
true
Expand Down Expand Up @@ -306,6 +354,7 @@ export async function generateApi(
endpointOverrides,
config: hooks,
operationNameSuffix,
operationIdTransformer,
}),
]
: []),
Expand Down Expand Up @@ -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);

Expand Down
33 changes: 26 additions & 7 deletions packages/rtk-query-codegen-openapi/src/generators/react-hooks.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigFile['hooks']>;
Expand All @@ -12,39 +11,49 @@ type GetReactHookNameParams = {
endpointOverrides: EndpointOverrides[] | undefined;
config: HooksConfigOptions;
operationNameSuffix?: string;
operationIdTransformer?: OperationIdTransformer;
};

type CreateBindingParams = {
operationDefinition: OperationDefinition;
overrides?: EndpointOverrides;
isLazy?: boolean;
operationNameSuffix?: string;
operationIdTransformer?: OperationIdTransformer;
};

const createBinding = ({
operationDefinition: { verb, path, operation },
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);
Expand All @@ -71,13 +80,15 @@ type GenerateReactHooksParams = {
endpointOverrides: EndpointOverrides[] | undefined;
config: HooksConfigOptions;
operationNameSuffix?: string;
operationIdTransformer?: OperationIdTransformer;
};
export const generateReactHooks = ({
exportName,
operationDefinitions,
endpointOverrides,
config,
operationNameSuffix,
operationIdTransformer,
}: GenerateReactHooksParams) =>
factory.createVariableStatement(
[factory.createModifier(ts.SyntaxKind.ExportKeyword)],
Expand All @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions packages/rtk-query-codegen-openapi/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
* <caption>Preserve exact casing (e.g. `fetchMyJWTPlease` stays `fetchMyJWTPlease`)</caption>
*
* ```ts
* operationIdTransformer: 'none'
* ```
*
* @example
* <caption>Custom transformer</caption>
*
* ```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;
Expand Down
Loading
Loading