diff --git a/docs/rtk-query/usage/code-generation.mdx b/docs/rtk-query/usage/code-generation.mdx index 5e84bd914a..cfa798a757 100644 --- a/docs/rtk-query/usage/code-generation.mdx +++ b/docs/rtk-query/usage/code-generation.mdx @@ -121,6 +121,7 @@ interface SimpleUsage { endpointOverrides?: EndpointOverrides[] flattenArg?: boolean useEnumType?: boolean + outputRegexConstants?: boolean httpResolverOptions?: SwaggerParser.HTTPResolverOptions } @@ -192,6 +193,47 @@ const withOverride: ConfigFile = { Setting `hooks: true` will generate `useQuery` and `useMutation` hook exports. If you also want `useLazyQuery` hooks generated or more granular control, you can also pass an object in the shape of: `{ queries: boolean; lazyQueries: boolean; mutations: boolean }`. +#### Generating regex constants for schema patterns + +If your OpenAPI schema uses the [`pattern` keyword](https://swagger.io/docs/specification/data-models/data-types/#pattern) to specify regex validation on string properties, you can export these patterns as JavaScript regex constants by setting `outputRegexConstants: true`. + +```ts no-transpile title="openapi-config.ts" +const config: ConfigFile = { + schemaFile: 'https://petstore3.swagger.io/api/v3/openapi.json', + apiFile: './src/store/emptyApi.ts', + outputFile: './src/store/petApi.ts', + outputRegexConstants: true, +} +``` + +For a schema with pattern-validated properties like: + +```yaml +User: + type: object + properties: + email: + type: string + pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + phone: + type: string + pattern: '^\+?[1-9]\d{1,14}$' +``` + +The codegen will generate: + +```ts no-transpile title="Generated output" +export const userEmailPattern = + /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ +export const userPhonePattern = /^\+?[1-9]\d{1,14}$/ +``` + +These constants can be used for client-side validation to ensure consistency with API expectations. + +:::note +Only string-type properties with non-empty `pattern` values will generate constants. The constant name follows the format `{typeName}{propertyName}Pattern` in camelCase. +::: + #### Multiple output files ```ts no-transpile title="openapi-config.ts" diff --git a/packages/rtk-query-codegen-openapi/src/generate.ts b/packages/rtk-query-codegen-openapi/src/generate.ts index 789457e9ef..d6f125964a 100644 --- a/packages/rtk-query-codegen-openapi/src/generate.ts +++ b/packages/rtk-query-codegen-openapi/src/generate.ts @@ -2,13 +2,13 @@ import camelCase from 'lodash.camelcase'; import path from 'node:path'; import ApiGenerator, { getOperationName as _getOperationName, - getReferenceName, - isReference, - supportDeepObjects, createPropertyAssignment, createQuestionToken, + getReferenceName, + isReference, isValidIdentifier, keywordType, + supportDeepObjects, } from 'oazapfts/generate'; import type { OpenAPIV3 } from 'openapi-types'; import ts from 'typescript'; @@ -87,6 +87,56 @@ function withQueryComment(node: T, def: QueryArgDefinition, h return node; } +function getPatternFromProperty( + property: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, + apiGen: ApiGenerator +): string | null { + const resolved = apiGen.resolve(property); + if (!resolved || typeof resolved !== 'object' || !('pattern' in resolved)) return null; + if (resolved.type !== 'string') return null; + const pattern = resolved.pattern; + return typeof pattern === 'string' && pattern.length > 0 ? pattern : null; +} + +function generateRegexConstantsForType( + typeName: string, + schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, + apiGen: ApiGenerator +): ts.VariableStatement[] { + const resolvedSchema = apiGen.resolve(schema); + if (!resolvedSchema || !('properties' in resolvedSchema) || !resolvedSchema.properties) return []; + + const constants: ts.VariableStatement[] = []; + + for (const [propertyName, property] of Object.entries(resolvedSchema.properties)) { + const pattern = getPatternFromProperty(property, apiGen); + if (!pattern) continue; + + const constantName = camelCase(`${typeName} ${propertyName} Pattern`); + const escapedPattern = pattern.replaceAll('/', String.raw`\/`); + const regexLiteral = factory.createRegularExpressionLiteral(`/${escapedPattern}/`); + + constants.push( + factory.createVariableStatement( + [factory.createModifier(ts.SyntaxKind.ExportKeyword)], + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier(constantName), + undefined, + undefined, + regexLiteral + ), + ], + ts.NodeFlags.Const + ) + ) + ); + } + + return constants; +} + export function getOverrides( operation: OperationDefinition, endpointOverrides?: EndpointOverrides[] @@ -119,6 +169,7 @@ export async function generateApi( httpResolverOptions, useUnknown = false, esmExtensions = false, + outputRegexConstants = false, }: GenerationOptions ) { const v3Doc = (v3DocCache[spec] ??= await getV3Doc(spec, httpResolverOptions)); @@ -206,7 +257,18 @@ export async function generateApi( undefined ), ...Object.values(interfaces), - ...apiGen.aliases, + ...(outputRegexConstants + ? apiGen.aliases.flatMap((alias) => { + if (!ts.isInterfaceDeclaration(alias) && !ts.isTypeAliasDeclaration(alias)) return [alias]; + + const typeName = alias.name.escapedText.toString(); + const schema = v3Doc.components?.schemas?.[typeName]; + if (!schema) return [alias]; + + const regexConstants = generateRegexConstantsForType(typeName, schema, apiGen); + return regexConstants.length > 0 ? [alias, ...regexConstants] : [alias]; + }) + : apiGen.aliases), ...apiGen.enumAliases, ...(hooks ? [ diff --git a/packages/rtk-query-codegen-openapi/src/types.ts b/packages/rtk-query-codegen-openapi/src/types.ts index f72dcece35..b86375cdfb 100644 --- a/packages/rtk-query-codegen-openapi/src/types.ts +++ b/packages/rtk-query-codegen-openapi/src/types.ts @@ -126,6 +126,11 @@ export interface CommonOptions { * 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; } export type TextMatcher = string | RegExp | (string | RegExp)[]; diff --git a/packages/rtk-query-codegen-openapi/test/__snapshots__/cli.test.ts.snap b/packages/rtk-query-codegen-openapi/test/__snapshots__/cli.test.ts.snap index 57b10e27e1..afd08b3847 100644 --- a/packages/rtk-query-codegen-openapi/test/__snapshots__/cli.test.ts.snap +++ b/packages/rtk-query-codegen-openapi/test/__snapshots__/cli.test.ts.snap @@ -241,6 +241,7 @@ export type User = { email?: string; password?: string; phone?: string; + website?: string; /** User Status */ userStatus?: number; }; @@ -488,6 +489,7 @@ export type User = { email?: string; password?: string; phone?: string; + website?: string; /** User Status */ userStatus?: number; }; diff --git a/packages/rtk-query-codegen-openapi/test/__snapshots__/generateEndpoints.test.ts.snap b/packages/rtk-query-codegen-openapi/test/__snapshots__/generateEndpoints.test.ts.snap index b0bd029779..eaa746bff7 100644 --- a/packages/rtk-query-codegen-openapi/test/__snapshots__/generateEndpoints.test.ts.snap +++ b/packages/rtk-query-codegen-openapi/test/__snapshots__/generateEndpoints.test.ts.snap @@ -241,6 +241,7 @@ export type User = { email?: string; password?: string; phone?: string; + website?: string; /** User Status */ userStatus?: number; }; @@ -488,6 +489,7 @@ export type User = { email?: string; password?: string; phone?: string; + website?: string; /** User Status */ userStatus?: number; }; @@ -791,6 +793,7 @@ export type User = { email?: string | undefined; password?: string | undefined; phone?: string | undefined; + website?: string | undefined; /** User Status */ userStatus?: number | undefined; }; @@ -1196,6 +1199,7 @@ export type User = { email?: string | undefined; password?: string | undefined; phone?: string | undefined; + website?: string | undefined; /** User Status */ userStatus?: number | undefined; }; @@ -1477,6 +1481,7 @@ export type User = { email?: string | undefined; password?: string | undefined; phone?: string | undefined; + website?: string | undefined; /** User Status */ userStatus?: number | undefined; }; @@ -1776,6 +1781,7 @@ export type User = { email?: string | undefined; password?: string | undefined; phone?: string | undefined; + website?: string | undefined; /** User Status */ userStatus?: number | undefined; }; @@ -2057,6 +2063,7 @@ export type User = { email?: string | undefined; password?: string | undefined; phone?: string | undefined; + website?: string | undefined; /** User Status */ userStatus?: number | undefined; }; @@ -2329,6 +2336,7 @@ export type User = { email?: string | undefined; password?: string | undefined; phone?: string | undefined; + website?: string | undefined; /** User Status */ userStatus?: number | undefined; }; @@ -2886,6 +2894,7 @@ export type User = { email?: string | undefined; password?: string | undefined; phone?: string | undefined; + website?: string | undefined; /** User Status */ userStatus?: number | undefined; }; @@ -3513,6 +3522,7 @@ export type User = { email?: string | undefined; password?: string | undefined; phone?: string | undefined; + website?: string | undefined; /** User Status */ userStatus?: number | undefined; }; diff --git a/packages/rtk-query-codegen-openapi/test/fixtures/petstore.json b/packages/rtk-query-codegen-openapi/test/fixtures/petstore.json index c82b9ab210..caad003667 100644 --- a/packages/rtk-query-codegen-openapi/test/fixtures/petstore.json +++ b/packages/rtk-query-codegen-openapi/test/fixtures/petstore.json @@ -1015,18 +1015,27 @@ }, "email": { "type": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", "example": "john@email.com" }, "password": { "type": "string", - "example": "12345" + "example": "12345", + "pattern": "" }, "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 @@ -1044,7 +1053,8 @@ "format": "int64" }, "name": { - "type": "string" + "type": "string", + "pattern": "^\\S+$" } }, "xml": { diff --git a/packages/rtk-query-codegen-openapi/test/fixtures/petstore.yaml b/packages/rtk-query-codegen-openapi/test/fixtures/petstore.yaml index 2e9d8c6f0e..f3ef9ec2bb 100644 --- a/packages/rtk-query-codegen-openapi/test/fixtures/petstore.yaml +++ b/packages/rtk-query-codegen-openapi/test/fixtures/petstore.yaml @@ -695,15 +695,23 @@ components: 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 @@ -717,6 +725,7 @@ components: format: int64 name: type: string + pattern: '^\S+$' xml: name: tag Pet: diff --git a/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts b/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts index bb823e913e..c3cd943850 100644 --- a/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts +++ b/packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts @@ -648,6 +648,79 @@ describe('query parameters', () => { }); }); +describe('regex constants', () => { + it('should export regex constants for patterns', async () => { + const api = await generateEndpoints({ + unionUndefined: true, + apiFile: './fixtures/emptyApi.ts', + schemaFile: resolve(__dirname, 'fixtures/petstore.json'), + outputRegexConstants: true, + }); + + expect(api).toContain(String.raw`export const tagNamePattern = /^\S+$/`); + expect(api).toContain(String.raw`export const userEmailPattern`); + expect(api).toContain(String.raw`/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/`); + expect(api).toContain(String.raw`export const userPhonePattern = /^\+?[1-9]\d{1,14}$/`); + }); + + it('should not export constants for invalid patterns', async () => { + const api = await generateEndpoints({ + unionUndefined: true, + apiFile: './fixtures/emptyApi.ts', + schemaFile: resolve(__dirname, 'fixtures/petstore.json'), + outputRegexConstants: true, + }); + + // Empty pattern should not generate a constant + expect(api).not.toContain('userPasswordPattern'); + expect(api).not.toContain('passwordPattern'); + + // Pattern on non-string property (integer) should not generate a constant + expect(api).not.toContain('userUserStatusPattern'); + expect(api).not.toContain('userStatusPattern'); + }); + + it('should export regex constants for patterns from YAML file', async () => { + const api = await generateEndpoints({ + unionUndefined: true, + apiFile: './fixtures/emptyApi.ts', + schemaFile: resolve(__dirname, 'fixtures/petstore.yaml'), + outputRegexConstants: true, + }); + + expect(api).toContain(String.raw`export const tagNamePattern = /^\S+$/`); + expect(api).toContain(String.raw`export const userEmailPattern`); + expect(api).toContain(String.raw`/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/`); + expect(api).toContain(String.raw`export const userPhonePattern = /^\+?[1-9]\d{1,14}$/`); + }); + + it('should not export regex constants when outputRegexConstants is false', async () => { + const api = await generateEndpoints({ + unionUndefined: true, + apiFile: './fixtures/emptyApi.ts', + schemaFile: resolve(__dirname, 'fixtures/petstore.json'), + outputRegexConstants: false, + }); + + expect(api).not.toContain('Pattern = /'); + expect(api).not.toContain('tagNamePattern'); + expect(api).not.toContain('userEmailPattern'); + expect(api).not.toContain('userPhonePattern'); + }); + + it('should properly escape forward slashes in patterns', async () => { + const api = await generateEndpoints({ + unionUndefined: true, + apiFile: './fixtures/emptyApi.ts', + schemaFile: resolve(__dirname, 'fixtures/petstore.json'), + outputRegexConstants: true, + }); + + // The userWebsitePattern should have escaped forward slashes + expect(api).toContain(String.raw`export const userWebsitePattern = /^https?:\/\/[^\s]+$/`); + }); +}); + describe('esmExtensions option', () => { beforeAll(async () => { if (!(await isDir(tmpDir))) {