diff --git a/README.md b/README.md index fa6114f..9aa6977 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,60 @@ This library combines common `@nestjs/swagger`, `class-transformer` and `class-validator` decorators that are used together into one decorator for full Nest.js DTO lifecycle including OpenAPI schema descriptions. +DTO without `nestjs-swagger-dto`: + +```ts +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsString, MaxLength, MinLength, ValidateNested } from 'class-validator'; + +export class RoleDto { + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(256) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(256) + description?: string; + + @ApiProperty({ enum: RoleStatus, enumName: 'RoleStatus' }) + status!: RoleStatus; + + @ValidateNested({ each: true }) + @Type(() => PermissionDto) + @ApiProperty({ type: [PermissionDto] }) + permissions!: PermissionDto[]; +} + +``` + +DTO with `nestjs-swagger-dto`: + +```ts +import { IsEnum, IsNested, IsString } from 'nestjs-swagger-dto'; + +class RoleDto { + @IsString({ + optional: true, + minLength: 3, + maxLength: 256, + }) + name?: string; + + @IsString({ optional: true, maxLength: 255 }) + description?: string; + + @IsEnum({ enum: { RoleStatus } }) + status!: RoleStatus; + + @IsNested({ type: PermissionDto, isArray: true }) + permissions!: PermissionDto[]; +} +``` + ## Installation ```sh diff --git a/src/decorators/is-number.spec.ts b/src/decorators/is-number.spec.ts index 9f18cf4..813e9c8 100644 --- a/src/decorators/is-number.spec.ts +++ b/src/decorators/is-number.spec.ts @@ -1,6 +1,6 @@ import { Result } from 'true-myth'; -import { input, make } from '../../tests/helpers'; +import { generateSchemas, input, make, output } from '../../tests/helpers'; import { IsNumber } from '../nestjs-swagger-dto'; describe('IsNumber', () => { @@ -134,6 +134,57 @@ describe('IsNumber', () => { }); }); + describe('integer', () => { + class Test { + @IsNumber({ type: 'integer', format: 'int32' }) + integerField!: number; + } + + it('generates correct schema', async () => { + expect(await generateSchemas([Test])).toStrictEqual({ + Test: { + type: 'object', + properties: { + integerField: { + type: 'integer', + format: 'int32', + }, + }, + required: ['integerField'], + }, + }); + }); + + it('transforms to plain without converting floats to ints', async () => { + const dto = make(Test, { integerField: 10.5 }); + expect(output(dto)).toStrictEqual({ integerField: 10.5 }); + }); + + it('accepts integers', async () => { + expect(await input(Test, { integerField: 10 })).toStrictEqual( + Result.ok(make(Test, { integerField: 10 })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { integerField: 10.5 }, + { integerField: 'true' }, + { integerField: 'false' }, + { integerField: [] }, + { integerField: {} }, + { integerField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await input(Test, testValue)).toStrictEqual( + Result.err('integerField must be a number conforming to the specified constraints') + ); + } + }); + }); + describe('default and stringified', () => { class Test { @IsNumber({ optional: true, stringified: true, default: 25 }) diff --git a/src/decorators/is-number.ts b/src/decorators/is-number.ts index 24f5058..12a6a3b 100644 --- a/src/decorators/is-number.ts +++ b/src/decorators/is-number.ts @@ -3,19 +3,41 @@ import { IsNumber as IsNumberCV, isNumberString, Max, Min } from 'class-validato import { compose, noop, PropertyOptions } from '../core'; +/** + * *NOTE*: If type is not set it defaults to number + * + * *NOTE*: Format is only used for OpenAPI spec + * + * **WARNING**: Setting `type: 'integer'` will not convert floats to integers during `classToPlain` + */ export const IsNumber = ({ min, max, stringified, + type, + format, ...base }: PropertyOptions< number, - { min?: number; max?: number; stringified?: true } + { + min?: number; + max?: number; + stringified?: true; + } & ( + | { + type?: undefined; + format?: 'float' | 'double'; + } + | { + type: 'integer'; + format?: 'int32' | 'int64'; + } + ) > = {}): PropertyDecorator => compose( - { type: 'number', minimum: min, maximum: max }, + { type: type ?? 'number', format, minimum: min, maximum: max }, base, - IsNumberCV(undefined, { each: !!base.isArray }), + IsNumberCV({ ...(type === 'integer' && { maxDecimalPlaces: 0 }) }, { each: !!base.isArray }), stringified ? Transform(({ value }) => (isNumberString(value) ? Number(value) : value)) : noop, min !== undefined ? Min(min, { each: !!base.isArray }) : noop, max !== undefined ? Max(max, { each: !!base.isArray }) : noop