diff --git a/lib/decorators/api-property.decorator.ts b/lib/decorators/api-property.decorator.ts index 11ad63557..d122b1fcf 100644 --- a/lib/decorators/api-property.decorator.ts +++ b/lib/decorators/api-property.decorator.ts @@ -71,7 +71,9 @@ export function createApiPropertyDecorator( const enumValues = getEnumValues(options.enum); options.enum = enumValues; - options.type = getEnumType(enumValues); + if (!options.type) { + options.type = getEnumType(enumValues); + } } if (Array.isArray(options.type)) { diff --git a/lib/plugin/utils/ast-utils.ts b/lib/plugin/utils/ast-utils.ts index 54021ee03..fc60b27de 100644 --- a/lib/plugin/utils/ast-utils.ts +++ b/lib/plugin/utils/ast-utils.ts @@ -51,7 +51,14 @@ export function getTypeArguments(type: Type) { } export function isBoolean(type: Type) { - return hasFlag(type, TypeFlags.Boolean); + return ( + hasFlag(type, TypeFlags.Boolean) || + hasFlag(type, TypeFlags.BooleanLiteral) + ); +} + +export function isBooleanLiteral(type: Type) { + return hasFlag(type, TypeFlags.BooleanLiteral) && !type.isUnion(); } export function isString(type: Type) { diff --git a/lib/services/schema-object-factory.ts b/lib/services/schema-object-factory.ts index 3b94b61d8..b7abacdbe 100644 --- a/lib/services/schema-object-factory.ts +++ b/lib/services/schema-object-factory.ts @@ -586,7 +586,7 @@ export class SchemaObjectFactory { const enumValues = getEnumValues(propertyCompilerMetadata.enum); propertyCompilerMetadata.items = { - type: getEnumType(enumValues), + type: propertyCompilerMetadata.items?.type ?? getEnumType(enumValues), enum: enumValues }; delete propertyCompilerMetadata.enum; @@ -594,7 +594,9 @@ export class SchemaObjectFactory { const enumValues = getEnumValues(propertyCompilerMetadata.enum); propertyCompilerMetadata.enum = enumValues; - propertyCompilerMetadata.type = getEnumType(enumValues); + if (!propertyCompilerMetadata.type) { + propertyCompilerMetadata.type = getEnumType(enumValues); + } } const propertyMetadata = this.mergePropertyWithMetadata( key, diff --git a/lib/types/swagger-enum.type.ts b/lib/types/swagger-enum.type.ts index 3830a512d..84f1c171d 100644 --- a/lib/types/swagger-enum.type.ts +++ b/lib/types/swagger-enum.type.ts @@ -1,5 +1,6 @@ export type SwaggerEnumType = | string[] | number[] - | (string | number)[] + | boolean[] + | (string | number | boolean)[] | Record; diff --git a/lib/utils/enum.utils.ts b/lib/utils/enum.utils.ts index 635c7bb44..de86d467e 100644 --- a/lib/utils/enum.utils.ts +++ b/lib/utils/enum.utils.ts @@ -5,7 +5,7 @@ import { SwaggerEnumType } from '../types/swagger-enum.type'; export function getEnumValues( enumType: SwaggerEnumType | (() => SwaggerEnumType) -): string[] | number[] { +): string[] | number[] | boolean[] { if (typeof enumType === 'function') { return getEnumValues(enumType()); } @@ -37,9 +37,14 @@ export function getEnumValues( .map((key) => enumType[key]); } -export function getEnumType(values: (string | number)[]): 'string' | 'number' { +export function getEnumType( + values: (string | number | boolean)[] +): 'string' | 'number' | 'boolean' { const hasString = values.filter(isString).length > 0; - return hasString ? 'string' : 'number'; + if (hasString) return 'string'; + const hasBoolean = values.some((v) => typeof v === 'boolean'); + if (hasBoolean) return 'boolean'; + return 'number'; } export function addEnumArraySchema( diff --git a/test/plugin/fixtures/boolean-literal.dto.ts b/test/plugin/fixtures/boolean-literal.dto.ts new file mode 100644 index 000000000..eff9a36ea --- /dev/null +++ b/test/plugin/fixtures/boolean-literal.dto.ts @@ -0,0 +1,16 @@ +export const booleanLiteralDtoText = ` +export class BooleanLiteralDto { + propTrue: true; + propFalse: false; + propBoolLitUnion: true | false; + propOptionalTrue?: true; +} +`; + +export const booleanLiteralDtoTextTranspiled = `import * as openapi from "@nestjs/swagger"; +export class BooleanLiteralDto { + static _OPENAPI_METADATA_FACTORY() { + return { propTrue: { required: true, type: () => Boolean }, propFalse: { required: true, type: () => Boolean }, propBoolLitUnion: { required: true, type: () => Boolean }, propOptionalTrue: { required: false, type: () => Boolean } }; + } +} +`; diff --git a/test/plugin/model-class-visitor.spec.ts b/test/plugin/model-class-visitor.spec.ts index 6c03a8fc1..0b45aa537 100644 --- a/test/plugin/model-class-visitor.spec.ts +++ b/test/plugin/model-class-visitor.spec.ts @@ -48,6 +48,10 @@ import { stringLiteralDtoText, stringLiteralDtoTextTranspiled } from './fixtures/string-literal.dto'; +import { + booleanLiteralDtoText, + booleanLiteralDtoTextTranspiled +} from './fixtures/boolean-literal.dto'; describe('API model properties', () => { it('should add the metadata factory when no decorators exist, and generated propertyKey is title', () => { @@ -259,6 +263,33 @@ describe('API model properties', () => { expect(result.outputText).toEqual(stringLiteralDtoTextTranspiled); }); + it('should support & understand boolean literal types', () => { + const options: ts.CompilerOptions = { + module: ts.ModuleKind.ES2020, + target: ts.ScriptTarget.ES2020, + newLine: ts.NewLineKind.LineFeed, + noEmitHelpers: true, + experimentalDecorators: true, + strict: true + }; + const filename = 'boolean-literal.dto.ts'; + const fakeProgram = ts.createProgram([filename], options); + + const result = ts.transpileModule(booleanLiteralDtoText, { + compilerOptions: options, + fileName: filename, + transformers: { + before: [ + before( + { introspectComments: true, classValidatorShim: true }, + fakeProgram + ) + ] + } + }); + expect(result.outputText).toEqual(booleanLiteralDtoTextTranspiled); + }); + it('should support & understand parameter properties', () => { const options: ts.CompilerOptions = { module: ts.ModuleKind.ES2020, diff --git a/test/services/schema-object-factory.spec.ts b/test/services/schema-object-factory.spec.ts index 371a8713d..3cf9fcf94 100644 --- a/test/services/schema-object-factory.spec.ts +++ b/test/services/schema-object-factory.spec.ts @@ -818,5 +818,70 @@ describe('SchemaObjectFactory', () => { expect(result.type).toBe('array'); }); }); + + describe('boolean enum and explicit type preservation', () => { + it('should produce type "boolean" with enum [true, false]', () => { + class BoolEnumDto { + @ApiProperty({ enum: [true, false] }) + active: boolean; + } + + const schemas: Record = {}; + schemaObjectFactory.exploreModelSchema(BoolEnumDto, schemas); + + expect(schemas.BoolEnumDto).toBeDefined(); + const props = schemas.BoolEnumDto.properties as any; + expect(props.active).toEqual({ + type: 'boolean', + enum: [true, false] + }); + }); + + it('should produce type "boolean" with enum [true]', () => { + class TrueLiteralDto { + @ApiProperty({ enum: [true] }) + flag: boolean; + } + + const schemas: Record = {}; + schemaObjectFactory.exploreModelSchema(TrueLiteralDto, schemas); + + const props = schemas.TrueLiteralDto.properties as any; + expect(props.flag).toEqual({ + type: 'boolean', + enum: [true] + }); + }); + + it('should preserve explicit type when enum is also provided', () => { + class ExplicitTypeBoolDto { + @ApiProperty({ type: 'boolean', enum: [true] }) + flag: boolean; + } + + const schemas: Record = {}; + schemaObjectFactory.exploreModelSchema(ExplicitTypeBoolDto, schemas); + + const props = schemas.ExplicitTypeBoolDto.properties as any; + expect(props.flag).toEqual({ + type: 'boolean', + enum: [true] + }); + }); + + it('should preserve explicit type "string" even when enum values are numbers', () => { + class ExplicitTypeStringDto { + @ApiProperty({ type: 'string', enum: [1, 2, 3] }) + code: string; + } + + const schemas: Record = {}; + schemaObjectFactory.exploreModelSchema(ExplicitTypeStringDto, schemas); + + const props = schemas.ExplicitTypeStringDto.properties as any; + expect(props.code.type).toBe('string'); + expect(props.code.enum).toEqual([1, 2, 3]); + }); + }); }); diff --git a/test/utils/enum-utils.spec.ts b/test/utils/enum-utils.spec.ts new file mode 100644 index 000000000..d91cc6838 --- /dev/null +++ b/test/utils/enum-utils.spec.ts @@ -0,0 +1,58 @@ +import { getEnumType, getEnumValues } from '../../lib/utils/enum.utils'; + +describe('enum.utils', () => { + describe('getEnumType', () => { + it('should return "string" for string enum values', () => { + expect(getEnumType(['a', 'b'])).toBe('string'); + }); + + it('should return "number" for number enum values', () => { + expect(getEnumType([1, 2])).toBe('number'); + }); + + it('should return "boolean" for boolean enum values', () => { + expect(getEnumType([true, false])).toBe('boolean'); + }); + + it('should return "boolean" for a single true value', () => { + expect(getEnumType([true])).toBe('boolean'); + }); + + it('should return "boolean" for a single false value', () => { + expect(getEnumType([false])).toBe('boolean'); + }); + + it('should return "string" for mixed string/number values', () => { + expect(getEnumType(['a', 1])).toBe('string'); + }); + }); + + describe('getEnumValues', () => { + it('should pass through boolean arrays unchanged', () => { + expect(getEnumValues([true, false])).toEqual([true, false]); + }); + + it('should pass through string arrays unchanged', () => { + expect(getEnumValues(['a', 'b'])).toEqual(['a', 'b']); + }); + + it('should pass through number arrays unchanged', () => { + expect(getEnumValues([1, 2])).toEqual([1, 2]); + }); + + it('should resolve lazy enum types', () => { + const lazyEnum = () => ['x', 'y']; + expect(getEnumValues(lazyEnum)).toEqual(['x', 'y']); + }); + + it('should extract values from TypeScript numeric enum objects', () => { + const numericEnum = { 0: 'A', 1: 'B', A: 0, B: 1 }; + expect(getEnumValues(numericEnum)).toEqual([0, 1]); + }); + + it('should extract values from TypeScript string enum objects', () => { + const stringEnum = { A: 'alpha', B: 'beta' }; + expect(getEnumValues(stringEnum)).toEqual(['alpha', 'beta']); + }); + }); +});