From 0b2490171e8ab7ca4dc3492ddddb7052708ca5cf Mon Sep 17 00:00:00 2001 From: SUNNY_KIM Date: Tue, 19 Aug 2025 03:32:43 +0900 Subject: [PATCH] feat(decorators): accept RegExp in ApiProperty({ pattern }) while keeping schema pattern string Allow passing a RegExp to ApiProperty({ pattern }). The decorator normalizes the RegExp to an OpenAPI-compatible string (using .source), so the generated schema stays compliant where `pattern` must be a string. - Input DX: `pattern?: string | RegExp` (options only) - Output schema: `pattern` is always a string - No breaking changes - Add unit tests (5) for normalization and immutability Closes #3374 --- lib/decorators/api-property.decorator.ts | 76 +++++++++++++------ .../decorators/api-property.decorator.spec.ts | 71 +++++++++++++++++ 2 files changed, 122 insertions(+), 25 deletions(-) create mode 100644 test/decorators/api-property.decorator.spec.ts diff --git a/lib/decorators/api-property.decorator.ts b/lib/decorators/api-property.decorator.ts index 74588afe0..14539cde5 100644 --- a/lib/decorators/api-property.decorator.ts +++ b/lib/decorators/api-property.decorator.ts @@ -7,9 +7,18 @@ import { } from '../interfaces/schema-object-metadata.interface'; import { getEnumType, getEnumValues } from '../utils/enum.utils'; import { createPropertyDecorator, getTypeIsArrayTuple } from './helpers'; +import { + ReferenceObject, + SchemaObject +} from '../interfaces/open-api-spec.interface'; -export type ApiPropertyCommonOptions = SchemaObjectMetadata & { +type ApiPropertyCommonOptions = (Omit & { + pattern?: string | RegExp; + properties?: Record; + selfRequired?: boolean; +}) & { 'x-enumNames'?: string[]; + selfRequired?: boolean; /** * Lazy function returning the type for which the decorated property * can be used as an id @@ -38,6 +47,10 @@ const isEnumArray = ( items: any; } => opts.isArray && 'enum' in opts; +function normalizePattern(p?: string | RegExp): string | undefined { + return p instanceof RegExp ? p.source : p; +} + /** * @publicApi */ @@ -51,42 +64,55 @@ export function createApiPropertyDecorator( options: ApiPropertyOptions = {}, overrideExisting = true ): PropertyDecorator { - const [type, isArray] = getTypeIsArrayTuple(options.type, options.isArray); - options = { - ...options, + const normalized: ApiPropertyOptions = { + ...(options as any), + ...(Object.prototype.hasOwnProperty.call(options, 'pattern') + ? { pattern: normalizePattern((options as any).pattern) } + : null) + }; + + const [type, isArray] = getTypeIsArrayTuple( + (normalized as any).type, + (normalized as any).isArray + ); + + let finalOptions = { + ...(normalized as any), type, isArray } as ApiPropertyOptions; - if (isEnumArray(options)) { - options.type = 'array'; - - const enumValues = getEnumValues(options.enum); - options.items = { - type: getEnumType(enumValues), - enum: enumValues - }; - delete options.enum; - } else if ('enum' in options && options.enum !== undefined) { - const enumValues = getEnumValues(options.enum); - - options.enum = enumValues; - options.type = getEnumType(enumValues); - } - - if (Array.isArray(options.type)) { - options.type = 'array'; - options.items = { + if (isEnumArray(finalOptions as any)) { + const enumValues = getEnumValues((finalOptions as any).enum); + finalOptions = { + ...(finalOptions as any), type: 'array', items: { - type: options.type[0] + type: getEnumType(enumValues), + enum: enumValues } + } as any; + delete (finalOptions as any).enum; + } else if ( + 'enum' in (finalOptions as any) && + (finalOptions as any).enum !== undefined + ) { + const enumValues = getEnumValues((finalOptions as any).enum); + (finalOptions as any).enum = enumValues; + (finalOptions as any).type = getEnumType(enumValues); + } + + if (Array.isArray((finalOptions as any).type)) { + (finalOptions as any).items = { + type: 'array', + items: { type: (finalOptions as any).type[0] } }; + (finalOptions as any).type = 'array'; } return createPropertyDecorator( DECORATORS.API_MODEL_PROPERTIES, - options, + finalOptions, overrideExisting ); } diff --git a/test/decorators/api-property.decorator.spec.ts b/test/decorators/api-property.decorator.spec.ts new file mode 100644 index 000000000..8073bcffa --- /dev/null +++ b/test/decorators/api-property.decorator.spec.ts @@ -0,0 +1,71 @@ +import 'reflect-metadata'; +import { DECORATORS } from '../../lib/constants'; +import { ApiProperty } from '../../lib/decorators'; + +describe('ApiProperty', () => { + it('converts RegExp pattern to OpenAPI-compatible string (no slashes/flags)', () => { + class DtoWithRegExp { + @ApiProperty({ pattern: /^\w+$/gi }) + name!: string; + } + + const meta = Reflect.getMetadata( + DECORATORS.API_MODEL_PROPERTIES, + DtoWithRegExp.prototype, + 'name' + ); + + expect(meta.pattern).toBe('^\\w+$'); + }); + + it('keeps string pattern as-is', () => { + class DtoWithString { + @ApiProperty({ pattern: '^[a-z0-9]+$' }) + alias!: string; + } + + const meta = Reflect.getMetadata( + DECORATORS.API_MODEL_PROPERTIES, + DtoWithString.prototype, + 'alias' + ); + + expect(meta.pattern).toBe('^[a-z0-9]+$'); + }); + + it('supports RegExp created via constructor', () => { + class DtoCtor { + @ApiProperty({ pattern: new RegExp('^\\w+$', 'i') }) + v!: string; + } + const meta = Reflect.getMetadata( + DECORATORS.API_MODEL_PROPERTIES, + DtoCtor.prototype, + 'v' + ); + expect(meta.pattern).toBe('^\\w+$'); + expect(typeof meta.pattern).toBe('string'); + }); + + it('preserves escaped slashes in the pattern body', () => { + class DtoSlash { + @ApiProperty({ pattern: /^a\/b$/ }) + p!: string; + } + const meta = Reflect.getMetadata( + DECORATORS.API_MODEL_PROPERTIES, + DtoSlash.prototype, + 'p' + ); + expect(meta.pattern).toBe('^a\\/b$'); + }); + + it('does not mutate the original options object', () => { + const opts = { pattern: /^\w+$/ }; + class DtoNoMutate { + @ApiProperty(opts as any) + m!: string; + } + expect(opts.pattern instanceof RegExp).toBe(true); + }); +});