diff --git a/lib/services/schema-object-factory.ts b/lib/services/schema-object-factory.ts index 3b94b61d8..8c9939fcf 100644 --- a/lib/services/schema-object-factory.ts +++ b/lib/services/schema-object-factory.ts @@ -661,6 +661,22 @@ export class SchemaObjectFactory { | ParameterObject | (SchemaObject & { selfRequired?: boolean }) { const typeRef = nestedArrayType || metadata.type; + if (this.isConstEnumObject(typeRef as Record)) { + const enumValues = getEnumValues(typeRef as Record); + const enumType = getEnumType(enumValues); + const syntheticMetadata = { + ...metadata, + type: enumType, + enum: enumValues + } as SchemaObjectMetadata; + return this.createSchemaMetadata( + key, + syntheticMetadata, + schemas, + pendingSchemaRefs, + enumType + ); + } if (this.isObjectLiteral(typeRef as Record)) { const schemaFromObjectLiteral = this.createFromObjectLiteral( key, @@ -817,6 +833,28 @@ export class SchemaObjectFactory { return type === BigInt; } + /** + * Determines whether an object is a const-enum-like object (e.g., created with `as const`). + * Such objects have all values as primitive strings or numbers and no function values. + * This pattern is used when TypeScript namespaces merge a const object and a type alias: + * export const MyEnum = { A: 'a', B: 'b' } as const; + * export type MyEnum = (typeof MyEnum)[keyof typeof MyEnum]; + * With SWC, `Reflect.getMetadata('design:type', ...)` may resolve to the const object + * itself rather than `Object`, causing swagger to misinterpret it as an object literal schema. + */ + private isConstEnumObject(obj: Record): boolean { + if (typeof obj !== 'object' || !obj || Array.isArray(obj)) { + return false; + } + const values = Object.values(obj); + if (values.length === 0) { + return false; + } + return values.every( + (value) => typeof value === 'string' || typeof value === 'number' + ); + } + private extractPropertyModifiers( metadata: SchemaObjectMetadata ): [Partial, string[]] { diff --git a/test/services/schema-object-factory.spec.ts b/test/services/schema-object-factory.spec.ts index 371a8713d..fd1a1766a 100644 --- a/test/services/schema-object-factory.spec.ts +++ b/test/services/schema-object-factory.spec.ts @@ -1,4 +1,5 @@ import { ApiExtension, ApiProperty, ApiSchema } from '../../lib/decorators'; +import { DECORATORS } from '../../lib/constants'; import { Logger } from '@nestjs/common'; import { BaseParameterObject, @@ -818,5 +819,88 @@ describe('SchemaObjectFactory', () => { expect(result.type).toBe('array'); }); }); + + describe('SWC const-enum compatibility (issue #3326)', () => { + // Simulate the `as const` pattern that SWC resolves as the const object for design:type: + // export const MyEnum = { FOO: 'FOO', BAR: 'BAR' } as const; + // export type MyEnum = (typeof MyEnum)[keyof typeof MyEnum]; + const MyStringEnum = { FOO: 'FOO', BAR: 'BAR' } as const; + const MyNumericEnum = { ONE: 1, TWO: 2 } as const; + + it('should handle a const-enum object as design:type without throwing circular dependency error', () => { + // Simulate what SWC emits: design:type is the const object itself + class SwcDto { + someEnum: any; + } + Reflect.defineMetadata( + DECORATORS.API_MODEL_PROPERTIES, + { type: MyStringEnum, required: false }, + SwcDto.prototype, + 'someEnum' + ); + Reflect.defineMetadata( + DECORATORS.API_MODEL_PROPERTIES_ARRAY, + [':someEnum'], + SwcDto.prototype + ); + + const schemas: Record = {}; + expect(() => + schemaObjectFactory.exploreModelSchema(SwcDto as any, schemas) + ).not.toThrow(); + }); + + it('should produce an enum schema when design:type is a string const-enum object (SWC behavior)', () => { + class SwcStringEnumDto { + status: any; + } + Reflect.defineMetadata( + DECORATORS.API_MODEL_PROPERTIES, + { type: MyStringEnum, required: true }, + SwcStringEnumDto.prototype, + 'status' + ); + Reflect.defineMetadata( + DECORATORS.API_MODEL_PROPERTIES_ARRAY, + [':status'], + SwcStringEnumDto.prototype + ); + + const schemas: Record = {}; + schemaObjectFactory.exploreModelSchema(SwcStringEnumDto as any, schemas); + + expect(schemas['SwcStringEnumDto']).toBeDefined(); + const statusProp = schemas['SwcStringEnumDto'].properties['status']; + expect(statusProp).toBeDefined(); + // Should be treated as an enum, not throw a circular dependency error + expect(statusProp.type ?? statusProp.allOf).toBeDefined(); + }); + + it('should produce an enum schema when design:type is a numeric const-enum object (SWC behavior)', () => { + class SwcNumericEnumDto { + rank: any; + } + Reflect.defineMetadata( + DECORATORS.API_MODEL_PROPERTIES, + { type: MyNumericEnum, required: true }, + SwcNumericEnumDto.prototype, + 'rank' + ); + Reflect.defineMetadata( + DECORATORS.API_MODEL_PROPERTIES_ARRAY, + [':rank'], + SwcNumericEnumDto.prototype + ); + + const schemas: Record = {}; + expect(() => + schemaObjectFactory.exploreModelSchema(SwcNumericEnumDto as any, schemas) + ).not.toThrow(); + + expect(schemas['SwcNumericEnumDto']).toBeDefined(); + const rankProp = schemas['SwcNumericEnumDto'].properties['rank']; + expect(rankProp).toBeDefined(); + }); + }); });