Skip to content

Commit dcb1534

Browse files
author
Rhys
committed
feat: output regex patterns for generated schemas
1 parent 39e7c8f commit dcb1534

File tree

5 files changed

+130
-6
lines changed

5 files changed

+130
-6
lines changed

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

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import camelCase from 'lodash.camelcase';
22
import path from 'node:path';
33
import ApiGenerator, {
44
getOperationName as _getOperationName,
5-
getReferenceName,
6-
isReference,
7-
supportDeepObjects,
85
createPropertyAssignment,
96
createQuestionToken,
7+
getReferenceName,
8+
isReference,
109
isValidIdentifier,
1110
keywordType,
11+
supportDeepObjects,
1212
} from 'oazapfts/generate';
1313
import type { OpenAPIV3 } from 'openapi-types';
1414
import ts from 'typescript';
@@ -87,6 +87,56 @@ function withQueryComment<T extends ts.Node>(node: T, def: QueryArgDefinition, h
8787
return node;
8888
}
8989

90+
function getPatternFromProperty(
91+
property: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
92+
apiGen: ApiGenerator
93+
): string | null {
94+
const resolved = apiGen.resolve(property);
95+
if (!resolved || typeof resolved !== 'object' || !('pattern' in resolved)) return null;
96+
if (resolved.type !== 'string') return null;
97+
const pattern = resolved.pattern;
98+
return typeof pattern === 'string' && pattern.length > 0 ? pattern : null;
99+
}
100+
101+
function generateRegexConstantsForType(
102+
typeName: string,
103+
schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
104+
apiGen: ApiGenerator
105+
): ts.VariableStatement[] {
106+
const resolvedSchema = apiGen.resolve(schema);
107+
if (!resolvedSchema || !('properties' in resolvedSchema) || !resolvedSchema.properties) return [];
108+
109+
const constants: ts.VariableStatement[] = [];
110+
111+
for (const [propertyName, property] of Object.entries(resolvedSchema.properties)) {
112+
const pattern = getPatternFromProperty(property, apiGen);
113+
if (!pattern) continue;
114+
115+
const constantName = camelCase(`${typeName} ${propertyName} Pattern`);
116+
const escapedPattern = pattern.replaceAll('/', String.raw`\/`);
117+
const regexLiteral = factory.createRegularExpressionLiteral(`/${escapedPattern}/`);
118+
119+
constants.push(
120+
factory.createVariableStatement(
121+
[factory.createModifier(ts.SyntaxKind.ExportKeyword)],
122+
factory.createVariableDeclarationList(
123+
[
124+
factory.createVariableDeclaration(
125+
factory.createIdentifier(constantName),
126+
undefined,
127+
undefined,
128+
regexLiteral
129+
),
130+
],
131+
ts.NodeFlags.Const
132+
)
133+
)
134+
);
135+
}
136+
137+
return constants;
138+
}
139+
90140
export function getOverrides(
91141
operation: OperationDefinition,
92142
endpointOverrides?: EndpointOverrides[]
@@ -119,6 +169,7 @@ export async function generateApi(
119169
httpResolverOptions,
120170
useUnknown = false,
121171
esmExtensions = false,
172+
outputRegexConstants = false,
122173
}: GenerationOptions
123174
) {
124175
const v3Doc = (v3DocCache[spec] ??= await getV3Doc(spec, httpResolverOptions));
@@ -206,7 +257,18 @@ export async function generateApi(
206257
undefined
207258
),
208259
...Object.values(interfaces),
209-
...apiGen.aliases,
260+
...(outputRegexConstants
261+
? apiGen.aliases.flatMap((alias) => {
262+
if (!ts.isInterfaceDeclaration(alias) && !ts.isTypeAliasDeclaration(alias)) return [alias];
263+
264+
const typeName = alias.name.escapedText.toString();
265+
const schema = v3Doc.components?.schemas?.[typeName];
266+
if (!schema) return [alias];
267+
268+
const regexConstants = generateRegexConstantsForType(typeName, schema, apiGen);
269+
return regexConstants.length > 0 ? [alias, ...regexConstants] : [alias];
270+
})
271+
: apiGen.aliases),
210272
...apiGen.enumAliases,
211273
...(hooks
212274
? [

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export interface CommonOptions {
126126
* Will generate imports with file extension matching the expected compiled output of the api file
127127
*/
128128
esmExtensions?: boolean;
129+
/**
130+
* @default false
131+
* Will generate regex constants for pattern keywords in the schema
132+
*/
133+
outputRegexConstants?: boolean;
129134
}
130135

131136
export type TextMatcher = string | RegExp | (string | RegExp)[];

packages/rtk-query-codegen-openapi/test/fixtures/petstore.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,18 +1015,22 @@
10151015
},
10161016
"email": {
10171017
"type": "string",
1018+
"pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
10181019
"example": "john@email.com"
10191020
},
10201021
"password": {
10211022
"type": "string",
1022-
"example": "12345"
1023+
"example": "12345",
1024+
"pattern": ""
10231025
},
10241026
"phone": {
10251027
"type": "string",
1028+
"pattern": "^\\+?[1-9]\\d{1,14}$",
10261029
"example": "12345"
10271030
},
10281031
"userStatus": {
10291032
"type": "integer",
1033+
"pattern": "^[1-9]\\d{0,2}$",
10301034
"description": "User Status",
10311035
"format": "int32",
10321036
"example": 1
@@ -1044,7 +1048,8 @@
10441048
"format": "int64"
10451049
},
10461050
"name": {
1047-
"type": "string"
1051+
"type": "string",
1052+
"pattern": "^\\S+$"
10481053
}
10491054
},
10501055
"xml": {

packages/rtk-query-codegen-openapi/test/fixtures/petstore.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,15 +695,19 @@ components:
695695
example: James
696696
email:
697697
type: string
698+
pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
698699
example: john@email.com
699700
password:
700701
type: string
702+
pattern: ''
701703
example: '12345'
702704
phone:
703705
type: string
706+
pattern: '^\+?[1-9]\d{1,14}$'
704707
example: '12345'
705708
userStatus:
706709
type: integer
710+
pattern: '^[1-9]\d{0,2}$'
707711
description: User Status
708712
format: int32
709713
example: 1
@@ -717,6 +721,7 @@ components:
717721
format: int64
718722
name:
719723
type: string
724+
pattern: '^\S+$'
720725
xml:
721726
name: tag
722727
Pet:

packages/rtk-query-codegen-openapi/test/generateEndpoints.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,53 @@ describe('query parameters', () => {
648648
});
649649
});
650650

651+
describe('regex constants', () => {
652+
it('should export regex constants for patterns', async () => {
653+
const api = await generateEndpoints({
654+
unionUndefined: true,
655+
apiFile: './fixtures/emptyApi.ts',
656+
schemaFile: resolve(__dirname, 'fixtures/petstore.json'),
657+
outputRegexConstants: true,
658+
});
659+
660+
expect(api).toContain(String.raw`export const tagNamePattern = /^\S+$/`);
661+
expect(api).toContain(String.raw`export const userEmailPattern`);
662+
expect(api).toContain(String.raw`/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/`);
663+
expect(api).toContain(String.raw`export const userPhonePattern = /^\+?[1-9]\d{1,14}$/`);
664+
});
665+
666+
it('should not export constants for invalid patterns', async () => {
667+
const api = await generateEndpoints({
668+
unionUndefined: true,
669+
apiFile: './fixtures/emptyApi.ts',
670+
schemaFile: resolve(__dirname, 'fixtures/petstore.json'),
671+
outputRegexConstants: true,
672+
});
673+
674+
// Empty pattern should not generate a constant
675+
expect(api).not.toContain('userPasswordPattern');
676+
expect(api).not.toContain('passwordPattern');
677+
678+
// Pattern on non-string property (integer) should not generate a constant
679+
expect(api).not.toContain('userUserStatusPattern');
680+
expect(api).not.toContain('userStatusPattern');
681+
});
682+
683+
it('should export regex constants for patterns from YAML file', async () => {
684+
const api = await generateEndpoints({
685+
unionUndefined: true,
686+
apiFile: './fixtures/emptyApi.ts',
687+
schemaFile: resolve(__dirname, 'fixtures/petstore.yaml'),
688+
outputRegexConstants: true,
689+
});
690+
691+
expect(api).toContain(String.raw`export const tagNamePattern = /^\S+$/`);
692+
expect(api).toContain(String.raw`export const userEmailPattern`);
693+
expect(api).toContain(String.raw`/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/`);
694+
expect(api).toContain(String.raw`export const userPhonePattern = /^\+?[1-9]\d{1,14}$/`);
695+
});
696+
});
697+
651698
describe('esmExtensions option', () => {
652699
beforeAll(async () => {
653700
if (!(await isDir(tmpDir))) {

0 commit comments

Comments
 (0)