From ad0f505211834814949052c903fef282de731c3f Mon Sep 17 00:00:00 2001 From: Gleb Bashkatov Date: Wed, 26 May 2021 12:37:02 +0300 Subject: [PATCH] feat: implement decorators --- src/decorators/is-boolean.spec.ts | 218 +++++++++++++++++++++++++++++ src/decorators/is-boolean.ts | 17 +++ src/decorators/is-constant.spec.ts | 79 +++++++++++ src/decorators/is-constant.ts | 6 + src/decorators/is-enum.spec.ts | 104 ++++++++++++++ src/decorators/is-enum.ts | 13 ++ src/decorators/is-nested.spec.ts | 92 ++++++++++++ src/decorators/is-nested.ts | 31 ++++ src/decorators/is-number.spec.ts | 165 ++++++++++++++++++++++ src/decorators/is-number.ts | 19 +++ src/decorators/is-object.spec.ts | 83 +++++++++++ src/decorators/is-object.ts | 27 ++++ src/decorators/is-string.spec.ts | 171 ++++++++++++++++++++++ src/decorators/is-string.ts | 34 +++++ src/nestjs-swagger-dto.ts | 7 + 15 files changed, 1066 insertions(+) create mode 100644 src/decorators/is-boolean.spec.ts create mode 100644 src/decorators/is-boolean.ts create mode 100644 src/decorators/is-constant.spec.ts create mode 100644 src/decorators/is-constant.ts create mode 100644 src/decorators/is-enum.spec.ts create mode 100644 src/decorators/is-enum.ts create mode 100644 src/decorators/is-nested.spec.ts create mode 100644 src/decorators/is-nested.ts create mode 100644 src/decorators/is-number.spec.ts create mode 100644 src/decorators/is-number.ts create mode 100644 src/decorators/is-object.spec.ts create mode 100644 src/decorators/is-object.ts create mode 100644 src/decorators/is-string.spec.ts create mode 100644 src/decorators/is-string.ts create mode 100644 src/nestjs-swagger-dto.ts diff --git a/src/decorators/is-boolean.spec.ts b/src/decorators/is-boolean.spec.ts new file mode 100644 index 0000000..79c146b --- /dev/null +++ b/src/decorators/is-boolean.spec.ts @@ -0,0 +1,218 @@ +import { Result } from 'true-myth'; + +import { createDto, transform } from '../../tests/helpers'; +import { IsBoolean } from '../nestjs-swagger-dto'; + +describe('IsBoolean', () => { + describe('single', () => { + class Test { + @IsBoolean() + booleanField!: boolean; + } + + it('accepts booleans', async () => { + expect(await transform(Test, { booleanField: true })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: true })) + ); + expect(await transform(Test, { booleanField: false })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: false })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { booleanField: 'true' }, + { booleanField: 'false' }, + { booleanField: 'abc' }, + { booleanField: 0 }, + { booleanField: [] }, + { booleanField: {} }, + { booleanField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('booleanField must be a boolean value') + ); + } + }); + }); + + describe('stringified', () => { + class Test { + @IsBoolean({ stringified: true }) + booleanField!: boolean; + } + + it('accepts boolean strings and booleans', async () => { + expect(await transform(Test, { booleanField: 'true' })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: true })) + ); + expect(await transform(Test, { booleanField: 'false' })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: false })) + ); + expect(await transform(Test, { booleanField: true })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: true })) + ); + expect(await transform(Test, { booleanField: false })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: false })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { booleanField: 'True' }, + { booleanField: 'False' }, + { booleanField: 'true ' }, + { booleanField: ' false' }, + { booleanField: 'abc' }, + { booleanField: 0 }, + { booleanField: [] }, + { booleanField: {} }, + { booleanField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('booleanField must be a boolean value') + ); + } + }); + }); + + describe('optional', () => { + class Test { + @IsBoolean({ optional: true }) + booleanField?: boolean; + } + + it('accepts boolean and undefined', async () => { + expect(await transform(Test, { booleanField: true })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: true })) + ); + expect(await transform(Test, { booleanField: false })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: false })) + ); + expect(await transform(Test, {})).toStrictEqual(Result.ok(createDto(Test, {}))); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { booleanField: 'true' }, + { booleanField: 'false' }, + { booleanField: 'abc' }, + { booleanField: 0 }, + { booleanField: [] }, + { booleanField: {} }, + { booleanField: null }, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('booleanField must be a boolean value') + ); + } + }); + }); + + describe('nullable', () => { + class Test { + @IsBoolean({ nullable: true }) + booleanField!: boolean | null; + } + + it('accepts boolean and null', async () => { + expect(await transform(Test, { booleanField: true })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: true })) + ); + expect(await transform(Test, { booleanField: false })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: false })) + ); + expect(await transform(Test, { booleanField: null })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: null })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { booleanField: 'true' }, + { booleanField: 'false' }, + { booleanField: 'abc' }, + { booleanField: 0 }, + { booleanField: [] }, + { booleanField: {} }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('booleanField must be a boolean value') + ); + } + }); + }); + + describe('nullable and optional', () => { + class Test { + @IsBoolean({ optional: true, nullable: true }) + booleanField?: boolean | null; + } + + it('accepts boolean and null and undefined', async () => { + expect(await transform(Test, { booleanField: true })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: true })) + ); + expect(await transform(Test, { booleanField: false })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: false })) + ); + expect(await transform(Test, { booleanField: null })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: null })) + ); + expect(await transform(Test, {})).toStrictEqual(Result.ok(createDto(Test, {}))); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { booleanField: 'true' }, + { booleanField: 'false' }, + { booleanField: 'abc' }, + { booleanField: 0 }, + { booleanField: [] }, + { booleanField: {} }, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('booleanField must be a boolean value') + ); + } + }); + }); + + describe('array', () => { + class Test { + @IsBoolean({ isArray: true }) + booleanField!: boolean[]; + } + + it('accepts boolean arrays', async () => { + expect(await transform(Test, { booleanField: [true, false] })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: [true, false] })) + ); + expect(await transform(Test, { booleanField: [] })).toStrictEqual( + Result.ok(createDto(Test, { booleanField: [] })) + ); + }); + + it('rejects everything else', async () => { + expect(await transform(Test, { booleanField: true })).toStrictEqual( + Result.err('booleanField must be an array') + ); + expect(await transform(Test, { booleanField: [1, 2, 3] })).toStrictEqual( + Result.err('each value in booleanField must be a boolean value') + ); + }); + }); +}); diff --git a/src/decorators/is-boolean.ts b/src/decorators/is-boolean.ts new file mode 100644 index 0000000..a4187c2 --- /dev/null +++ b/src/decorators/is-boolean.ts @@ -0,0 +1,17 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean as IsBooleanCV } from 'class-validator'; + +import { Base, compose, noop } from '../core'; + +export const IsBoolean = ({ + stringified, + ...base +}: Base & { stringified?: true } = {}): PropertyDecorator => + compose( + { type: 'boolean' }, + base, + IsBooleanCV({ each: !!base.isArray }), + stringified + ? Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value)) + : noop + ); diff --git a/src/decorators/is-constant.spec.ts b/src/decorators/is-constant.spec.ts new file mode 100644 index 0000000..9c4e8ff --- /dev/null +++ b/src/decorators/is-constant.spec.ts @@ -0,0 +1,79 @@ +import { Result } from 'true-myth'; + +import { createDto, transform } from '../../tests/helpers'; +import { IsConstant } from '../nestjs-swagger-dto'; + +describe('IsConstant', () => { + describe('single', () => { + class Test { + @IsConstant({ value: 123 }) + constantField!: 123; + } + + it('accepts specified constant', async () => { + expect(await transform(Test, { constantField: 123 })).toStrictEqual( + Result.ok(createDto(Test, { constantField: 123 })) + ); + }); + + it('accepts specified constant', async () => { + class Test { + @IsConstant({ value: [1, 2, 3] }) + constantField!: [1, 2, 3]; + } + + expect(await transform(Test, { constantField: [1, 2, 3] })).toStrictEqual( + Result.err('constantField must be equal to 1, 2, 3') + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { constantField: 'true' }, + { constantField: 'false' }, + { constantField: 124 }, + { constantField: [] }, + { constantField: {} }, + { constantField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('constantField must be equal to 123') + ); + } + }); + }); + + describe('array', () => { + class Test { + @IsConstant({ value: 1, isArray: { length: 3 } }) + constantField!: [1, 1, 1]; + } + + it('accepts specified constant array', async () => { + expect(await transform(Test, { constantField: [1, 1, 1] })).toStrictEqual( + Result.ok(createDto(Test, { constantField: [1, 1, 1] })) + ); + }); + + it('rejects everything else', async () => { + expect(await transform(Test, { constantField: [1] })).toStrictEqual( + Result.err('constantField must contain at least 3 elements') + ); + expect(await transform(Test, { constantField: ['1'] })).toStrictEqual( + Result.err('each value in constantField must be equal to 1') + ); + expect(await transform(Test, { constantField: ['1', '1', '1'] })).toStrictEqual( + Result.err('each value in constantField must be equal to 1') + ); + expect(await transform(Test, { constantField: [1, 2, 3] })).toStrictEqual( + Result.err('each value in constantField must be equal to 1') + ); + expect(await transform(Test, { constantField: [2, 2, 2] })).toStrictEqual( + Result.err('each value in constantField must be equal to 1') + ); + }); + }); +}); diff --git a/src/decorators/is-constant.ts b/src/decorators/is-constant.ts new file mode 100644 index 0000000..0563405 --- /dev/null +++ b/src/decorators/is-constant.ts @@ -0,0 +1,6 @@ +import { Equals } from 'class-validator'; + +import { Base, compose } from '../core'; + +export const IsConstant = ({ value, ...base }: Base & { value: T }): PropertyDecorator => + compose({ enum: [value] }, base, Equals(value, { each: !!base.isArray })); diff --git a/src/decorators/is-enum.spec.ts b/src/decorators/is-enum.spec.ts new file mode 100644 index 0000000..b75a219 --- /dev/null +++ b/src/decorators/is-enum.spec.ts @@ -0,0 +1,104 @@ +import { Result } from 'true-myth'; + +import { createDto, transform } from '../../tests/helpers'; +import { IsEnum } from '../nestjs-swagger-dto'; + +describe('IsEnum', () => { + describe('array enum', () => { + class Test { + @IsEnum({ enum: [1, 2] }) + enumField!: 1 | 2; + } + + it('accepts specified enum', async () => { + expect(await transform(Test, { enumField: 1 })).toStrictEqual( + Result.ok(createDto(Test, { enumField: 1 })) + ); + expect(await transform(Test, { enumField: 2 })).toStrictEqual( + Result.ok(createDto(Test, { enumField: 2 })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { enumField: 'true' }, + { enumField: 'false' }, + { enumField: 0 }, + { enumField: [] }, + { enumField: {} }, + { enumField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('enumField must be one of the following values: 1, 2') + ); + } + }); + }); + + describe('object enum', () => { + enum Enum { + On = 'On', + Off = 'Off', + } + + class Test { + @IsEnum({ enum: Enum }) + enumField!: Enum; + } + + it('accepts specified enum', async () => { + expect(await transform(Test, { enumField: 'On' })).toStrictEqual( + Result.ok(createDto(Test, { enumField: Enum.On })) + ); + expect(await transform(Test, { enumField: 'Off' })).toStrictEqual( + Result.ok(createDto(Test, { enumField: Enum.Off })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { enumField: true }, + { enumField: 'abc' }, + { enumField: 0 }, + { enumField: [] }, + { enumField: {} }, + { enumField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('enumField must be a valid enum value') + ); + } + }); + }); + + describe('array', () => { + class Test { + @IsEnum({ enum: ['a', 'b'], isArray: true }) + enumField!: ('a' | 'b')[]; + } + + it('accepts enum arrays', async () => { + expect(await transform(Test, { enumField: ['a', 'b'] })).toStrictEqual( + Result.ok(createDto(Test, { enumField: ['a', 'b'] })) + ); + expect(await transform(Test, { enumField: [] })).toStrictEqual( + Result.ok(createDto(Test, { enumField: [] })) + ); + }); + + it('rejects everything else', async () => { + expect(await transform(Test, { enumField: true })).toStrictEqual( + Result.err('each value in enumField must be one of the following values: a, b') + ); + expect(await transform(Test, { enumField: ['a', 'b', 'c'] })).toStrictEqual( + Result.err('each value in enumField must be one of the following values: a, b') + ); + }); + }); +}); diff --git a/src/decorators/is-enum.ts b/src/decorators/is-enum.ts new file mode 100644 index 0000000..8dc3318 --- /dev/null +++ b/src/decorators/is-enum.ts @@ -0,0 +1,13 @@ +import { IsEnum as IsEnumCV, IsIn } from 'class-validator'; + +import { Base, compose } from '../core'; + +export const IsEnum = ({ + enum: e, + ...base +}: Base & { enum: T[] | Record }): PropertyDecorator => + compose( + { type: 'enum', enum: e }, + base, + Array.isArray(e) ? IsIn(e, { each: !!base.isArray }) : IsEnumCV(e, { each: !!base.isArray }) + ); diff --git a/src/decorators/is-nested.spec.ts b/src/decorators/is-nested.spec.ts new file mode 100644 index 0000000..d42cdef --- /dev/null +++ b/src/decorators/is-nested.spec.ts @@ -0,0 +1,92 @@ +import { Result } from 'true-myth'; + +import { createDto, transform } from '../../tests/helpers'; +import { IsNested, IsNumber } from '../nestjs-swagger-dto'; + +describe('IsNested', () => { + describe('single', () => { + class Nested { + @IsNumber() + numberField!: number; + } + + class Test { + @IsNested({ type: Nested }) + nestedField!: Nested; + } + + it('accepts nested object fields', async () => { + expect(await transform(Test, { nestedField: { numberField: 1 } })).toStrictEqual( + Result.ok(createDto(Test, { nestedField: createDto(Nested, { numberField: 1 }) })) + ); + }); + + it('rejects everything else', async () => { + expect(await transform(Test, { nestedField: { numberField: 'abc' } })).toStrictEqual( + Result.err('numberField must be a number conforming to the specified constraints') + ); + + const testValues: unknown[] = [ + { nestedField: 'abc' }, + { nestedField: false }, + { nestedField: [] }, + { nestedField: 0 }, + { nestedField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('nested property nestedField must be an object') + ); + } + }); + }); + + describe('array', () => { + class Nested { + @IsNumber() + numberField!: number; + } + + class Test { + @IsNested({ type: Nested, isArray: true }) + nestedField!: Nested[]; + } + + it('accepts nested object arrays', async () => { + expect( + await transform(Test, { + nestedField: [{ numberField: 1 }, { numberField: 2 }, { numberField: 3 }], + }) + ).toStrictEqual( + Result.ok( + createDto(Test, { + nestedField: [ + createDto(Nested, { numberField: 1 }), + createDto(Nested, { numberField: 2 }), + createDto(Nested, { numberField: 3 }), + ], + }) + ) + ); + expect(await transform(Test, { nestedField: [] })).toStrictEqual( + Result.ok(createDto(Test, { nestedField: [] })) + ); + }); + + it('rejects everything else', async () => { + expect( + await transform(Test, { + nestedField: [{ a: 1 }, { b: 2 }, { c: 3 }], + }) + ).toStrictEqual(Result.err('property a should not exist')); + expect(await transform(Test, { nestedField: true })).toStrictEqual( + Result.err('nestedField must be an array') + ); + expect(await transform(Test, { nestedField: ['a', 'b', 'c'] })).toStrictEqual( + Result.err('nested property nestedField must only contain objects') + ); + }); + }); +}); diff --git a/src/decorators/is-nested.ts b/src/decorators/is-nested.ts new file mode 100644 index 0000000..a7572ef --- /dev/null +++ b/src/decorators/is-nested.ts @@ -0,0 +1,31 @@ +import { ClassConstructor, Type } from 'class-transformer'; +import { IsObject, ValidateNested, ValidationArguments } from 'class-validator'; +import { noop } from 'rxjs'; + +import { Base, compose } from '../core'; + +const nestedFieldMessage = + (isArray: boolean) => + ({ property }: ValidationArguments) => + isArray + ? `nested property ${property} must only contain objects` + : `nested property ${property} must be an object`; + +export const IsNested = ({ + type, + ...base +}: { type: ClassConstructor } & Base): PropertyDecorator => + compose( + { type }, + base, + Type(() => type), + !base.isArray + ? IsObject({ + message: nestedFieldMessage(!!base.isArray), + }) + : noop, + ValidateNested({ + each: !!base.isArray, + message: nestedFieldMessage(!!base.isArray), + }) + ); diff --git a/src/decorators/is-number.spec.ts b/src/decorators/is-number.spec.ts new file mode 100644 index 0000000..2070387 --- /dev/null +++ b/src/decorators/is-number.spec.ts @@ -0,0 +1,165 @@ +import { Result } from 'true-myth'; + +import { createDto, transform } from '../../tests/helpers'; +import { IsNumber } from '../nestjs-swagger-dto'; + +describe('IsNumber', () => { + describe('single', () => { + class Test { + @IsNumber() + numberField!: number; + } + + it('accepts numbers', async () => { + expect(await transform(Test, { numberField: 1 })).toStrictEqual( + Result.ok(createDto(Test, { numberField: 1 })) + ); + expect(await transform(Test, { numberField: 1.1 })).toStrictEqual( + Result.ok(createDto(Test, { numberField: 1.1 })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { numberField: 'true' }, + { numberField: 'false' }, + { numberField: [] }, + { numberField: {} }, + { numberField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('numberField must be a number conforming to the specified constraints') + ); + } + }); + }); + + describe('bounds', () => { + it('checks minimum', async () => { + class Test { + @IsNumber({ min: 5 }) + numberField!: number; + } + + expect(await transform(Test, { numberField: 10 })).toStrictEqual( + Result.ok(createDto(Test, { numberField: 10 })) + ); + expect(await transform(Test, { numberField: 1 })).toStrictEqual( + Result.err('numberField must not be less than 5') + ); + expect(await transform(Test, { numberField: false })).toStrictEqual( + Result.err('numberField must be a number conforming to the specified constraints') + ); + }); + + it('checks maximum', async () => { + class Test { + @IsNumber({ max: 10 }) + numberField!: number; + } + + expect(await transform(Test, { numberField: 5 })).toStrictEqual( + Result.ok(createDto(Test, { numberField: 5 })) + ); + expect(await transform(Test, { numberField: 11 })).toStrictEqual( + Result.err('numberField must not be greater than 10') + ); + expect(await transform(Test, { numberField: false })).toStrictEqual( + Result.err('numberField must be a number conforming to the specified constraints') + ); + }); + + it('checks minimum and maximum', async () => { + class Test { + @IsNumber({ min: 5, max: 10 }) + numberField!: number; + } + + expect(await transform(Test, { numberField: 5 })).toStrictEqual( + Result.ok(createDto(Test, { numberField: 5 })) + ); + expect(await transform(Test, { numberField: 1 })).toStrictEqual( + Result.err('numberField must not be less than 5') + ); + expect(await transform(Test, { numberField: 11 })).toStrictEqual( + Result.err('numberField must not be greater than 10') + ); + expect(await transform(Test, { numberField: false })).toStrictEqual( + Result.err('numberField must be a number conforming to the specified constraints') + ); + }); + }); + + describe('stringified', () => { + class Test { + @IsNumber({ stringified: true }) + numberField!: number; + } + + it('accepts number strings and numbers', async () => { + expect(await transform(Test, { numberField: '10' })).toStrictEqual( + Result.ok(createDto(Test, { numberField: 10 })) + ); + expect(await transform(Test, { numberField: '-10.5' })).toStrictEqual( + Result.ok(createDto(Test, { numberField: -10.5 })) + ); + expect(await transform(Test, { numberField: 10 })).toStrictEqual( + Result.ok(createDto(Test, { numberField: 10 })) + ); + expect(await transform(Test, { numberField: -10.5 })).toStrictEqual( + Result.ok(createDto(Test, { numberField: -10.5 })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { numberField: 'true' }, + { numberField: 'false' }, + { numberField: '1 ' }, + { numberField: ' 1' }, + { numberField: [] }, + { numberField: {} }, + { numberField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('numberField must be a number conforming to the specified constraints') + ); + } + }); + }); + + describe('array', () => { + class Test { + @IsNumber({ isArray: true }) + numberField!: number[]; + } + + it('accepts number arrays', async () => { + expect(await transform(Test, { numberField: [1, 2, 3] })).toStrictEqual( + Result.ok(createDto(Test, { numberField: [1, 2, 3] })) + ); + expect(await transform(Test, { numberField: [] })).toStrictEqual( + Result.ok(createDto(Test, { numberField: [] })) + ); + }); + + it('rejects everything else', async () => { + expect(await transform(Test, { numberField: true })).toStrictEqual( + Result.err( + 'each value in numberField must be a number conforming to the specified constraints' + ) + ); + expect(await transform(Test, { numberField: ['a', 'b', 'c'] })).toStrictEqual( + Result.err( + 'each value in numberField must be a number conforming to the specified constraints' + ) + ); + }); + }); +}); diff --git a/src/decorators/is-number.ts b/src/decorators/is-number.ts new file mode 100644 index 0000000..2ecdaab --- /dev/null +++ b/src/decorators/is-number.ts @@ -0,0 +1,19 @@ +import { Transform } from 'class-transformer'; +import { IsNumber as IsNumberCV, isNumberString, Max, Min } from 'class-validator'; + +import { Base, compose, noop } from '../core'; + +export const IsNumber = ({ + min, + max, + stringified, + ...base +}: Base & { min?: number; max?: number; stringified?: true } = {}): PropertyDecorator => + compose( + { type: 'number', minimum: min, maximum: max }, + base, + IsNumberCV(undefined, { 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 + ); diff --git a/src/decorators/is-object.spec.ts b/src/decorators/is-object.spec.ts new file mode 100644 index 0000000..1e5dc03 --- /dev/null +++ b/src/decorators/is-object.spec.ts @@ -0,0 +1,83 @@ +import { Result } from 'true-myth'; + +import { createDto, transform } from '../../tests/helpers'; +import { IsObject } from '../nestjs-swagger-dto'; + +describe('IsObject', () => { + describe('single', () => { + class Test { + @IsObject() + objectField!: Record; + } + + it('accepts objects', async () => { + expect(await transform(Test, { objectField: { a: 1 } })).toStrictEqual( + Result.ok(createDto(Test, { objectField: { a: 1 } })) + ); + expect(await transform(Test, { objectField: {} })).toStrictEqual( + Result.ok(createDto(Test, { objectField: {} })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { objectField: 'abc' }, + { objectField: false }, + { objectField: [] }, + { objectField: 0 }, + { objectField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('objectField must be an object') + ); + } + }); + }); + + describe('properties', () => { + it('checks minProperties', async () => { + class Test { + @IsObject({ minProperties: 3 }) + objectField!: Record; + } + + expect(await transform(Test, { objectField: { a: 1, b: 2, c: 3 } })).toStrictEqual( + Result.ok(createDto(Test, { objectField: { a: 1, b: 2, c: 3 } })) + ); + expect(await transform(Test, { objectField: { a: 1 } })).toStrictEqual( + Result.err('objectField must have at least 3 properties') + ); + expect(await transform(Test, { objectField: false })).toStrictEqual( + Result.err('objectField must be an object') + ); + }); + }); + + describe('array', () => { + class Test { + @IsObject({ isArray: true }) + objectField!: Record[]; + } + + it('accepts object arrays', async () => { + expect(await transform(Test, { objectField: [{ a: 1 }, { b: 2 }, { c: 3 }] })).toStrictEqual( + Result.ok(createDto(Test, { objectField: [{ a: 1 }, { b: 2 }, { c: 3 }] })) + ); + expect(await transform(Test, { objectField: [] })).toStrictEqual( + Result.ok(createDto(Test, { objectField: [] })) + ); + }); + + it('rejects everything else', async () => { + expect(await transform(Test, { objectField: true })).toStrictEqual( + Result.err('each value in objectField must be an object') + ); + expect(await transform(Test, { objectField: ['a', 'b', 'c'] })).toStrictEqual( + Result.err('each value in objectField must be an object') + ); + }); + }); +}); diff --git a/src/decorators/is-object.ts b/src/decorators/is-object.ts new file mode 100644 index 0000000..72384e9 --- /dev/null +++ b/src/decorators/is-object.ts @@ -0,0 +1,27 @@ +import { IsObject as IsObjectCV, ValidateBy } from 'class-validator'; + +import { Base, compose, noop } from '../core'; + +export const IsObject = >({ + message, + minProperties, + ...base +}: Base & { + message?: string; + minProperties?: number; +} = {}): PropertyDecorator => + compose( + { type: 'object', minProperties }, + base, + IsObjectCV({ each: !!base.isArray, message }), + minProperties + ? ValidateBy({ + name: 'minProperties', + validator: { + validate: (v) => Object.keys(v).length >= minProperties, + defaultMessage: (args) => + `${args?.property} must have at least ${minProperties} properties`, + }, + }) + : noop + ); diff --git a/src/decorators/is-string.spec.ts b/src/decorators/is-string.spec.ts new file mode 100644 index 0000000..c33ab78 --- /dev/null +++ b/src/decorators/is-string.spec.ts @@ -0,0 +1,171 @@ +import { Result } from 'true-myth'; + +import { createDto, transform } from '../../tests/helpers'; +import { IsString } from '../nestjs-swagger-dto'; + +describe('IsString', () => { + describe('single', () => { + class Test { + @IsString() + stringField!: string; + } + + it('accepts strings', async () => { + expect(await transform(Test, { stringField: 'abc' })).toStrictEqual( + Result.ok(createDto(Test, { stringField: 'abc' })) + ); + expect(await transform(Test, { stringField: '' })).toStrictEqual( + Result.ok(createDto(Test, { stringField: '' })) + ); + }); + + it('rejects everything else', async () => { + const testValues: unknown[] = [ + { stringField: true }, + { stringField: 1 }, + { stringField: [] }, + { stringField: {} }, + { stringField: null }, + {}, + ]; + + for (const testValue of testValues) { + expect(await transform(Test, testValue)).toStrictEqual( + Result.err('stringField must be a string') + ); + } + }); + }); + + describe('length', () => { + it('checks minLength', async () => { + class Test { + @IsString({ minLength: 5 }) + stringField!: string; + } + + expect(await transform(Test, { stringField: 'aaaaa' })).toStrictEqual( + Result.ok(createDto(Test, { stringField: 'aaaaa' })) + ); + expect(await transform(Test, { stringField: 'aaa' })).toStrictEqual( + Result.err('stringField must be longer than or equal to 5 characters') + ); + expect(await transform(Test, { stringField: false })).toStrictEqual( + Result.err('stringField must be a string') + ); + }); + + it('checks maxLength', async () => { + class Test { + @IsString({ maxLength: 10 }) + stringField!: string; + } + + expect(await transform(Test, { stringField: 'aaa' })).toStrictEqual( + Result.ok(createDto(Test, { stringField: 'aaa' })) + ); + expect(await transform(Test, { stringField: 'a'.repeat(11) })).toStrictEqual( + Result.err('stringField must be shorter than or equal to 10 characters') + ); + expect(await transform(Test, { stringField: false })).toStrictEqual( + Result.err('stringField must be a string') + ); + }); + + it('checks minLength and maxLength', async () => { + class Test { + @IsString({ minLength: 5, maxLength: 10 }) + stringField!: string; + } + + expect(await transform(Test, { stringField: 'aaaaa' })).toStrictEqual( + Result.ok(createDto(Test, { stringField: 'aaaaa' })) + ); + expect(await transform(Test, { stringField: 'a' })).toStrictEqual( + Result.err('stringField must be longer than or equal to 5 characters') + ); + expect(await transform(Test, { stringField: 'a'.repeat(11) })).toStrictEqual( + Result.err('stringField must be shorter than or equal to 10 characters') + ); + expect(await transform(Test, { stringField: false })).toStrictEqual( + Result.err('stringField must be a string') + ); + }); + }); + + describe('pattern', () => { + it('accepts strings with specified pattern and rejects other ones', async () => { + class Test { + @IsString({ pattern: { regex: /^a+$/ } }) + stringField!: string; + } + + expect(await transform(Test, { stringField: 'aaaaa' })).toStrictEqual( + Result.ok(createDto(Test, { stringField: 'aaaaa' })) + ); + expect(await transform(Test, { stringField: 'aaab' })).toStrictEqual( + Result.err('stringField must match /^a+$/ regular expression') + ); + expect(await transform(Test, { stringField: false })).toStrictEqual( + Result.err('stringField must be a string') + ); + }); + + it('returns custom message if pattern fails to match', async () => { + class Test { + @IsString({ + pattern: { regex: /^a+$/, message: 'stringField must only contain "a" letters' }, + }) + stringField!: string; + } + + expect(await transform(Test, { stringField: 'aaab' })).toStrictEqual( + Result.err('stringField must only contain "a" letters') + ); + }); + }); + + describe('email', () => { + it('accepts email strings and rejects other ones', async () => { + class Test { + @IsString({ isEmail: true }) + stringField!: string; + } + + expect(await transform(Test, { stringField: 'abc@abc.abc' })).toStrictEqual( + Result.ok(createDto(Test, { stringField: 'abc@abc.abc' })) + ); + expect(await transform(Test, { stringField: 'aaab' })).toStrictEqual( + Result.err('stringField must be an email') + ); + expect(await transform(Test, { stringField: false })).toStrictEqual( + Result.err('stringField must be a string') + ); + }); + }); + + describe('array', () => { + class Test { + @IsString({ isArray: true }) + stringField!: string[]; + } + + it('accepts string arrays', async () => { + expect(await transform(Test, { stringField: ['a', 'b', 'c'] })).toStrictEqual( + Result.ok(createDto(Test, { stringField: ['a', 'b', 'c'] })) + ); + expect(await transform(Test, { stringField: [] })).toStrictEqual( + Result.ok(createDto(Test, { stringField: [] })) + ); + }); + + it('rejects everything else', async () => { + expect(await transform(Test, { stringField: true })).toStrictEqual( + Result.err('each value in stringField must be a string') + ); + expect(await transform(Test, { stringField: [1, 2, 3] })).toStrictEqual( + Result.err('each value in stringField must be a string') + ); + }); + }); +}); diff --git a/src/decorators/is-string.ts b/src/decorators/is-string.ts new file mode 100644 index 0000000..96e960c --- /dev/null +++ b/src/decorators/is-string.ts @@ -0,0 +1,34 @@ +import { IsEmail, IsString as IsStringCV, Length, Matches } from 'class-validator'; + +import { Base, compose, noop } from '../core'; + +export const IsString = ({ + maxLength, + minLength, + pattern, + canBeEmpty, + isEmail, + ...base +}: Base & { + canBeEmpty?: true; + maxLength?: number; + minLength?: number; + pattern?: { regex: RegExp; message?: string }; + isEmail?: true; +} = {}): PropertyDecorator => + compose( + { + type: 'string', + minLength, + maxLength, + ...(isEmail && { format: 'email' }), + pattern: pattern?.regex.toString().slice(1, 1), // removes trailing slashes + }, + base, + IsStringCV({ each: !!base.isArray }), + canBeEmpty || minLength !== undefined || maxLength !== undefined + ? Length(minLength ?? 0, maxLength, { each: !!base.isArray }) + : noop, + isEmail ? IsEmail(undefined, { each: !!base.isArray }) : noop, + pattern ? Matches(pattern.regex, { message: pattern.message, each: !!base.isArray }) : noop + ); diff --git a/src/nestjs-swagger-dto.ts b/src/nestjs-swagger-dto.ts new file mode 100644 index 0000000..d08731c --- /dev/null +++ b/src/nestjs-swagger-dto.ts @@ -0,0 +1,7 @@ +export * from './decorators/is-boolean'; +export * from './decorators/is-constant'; +export * from './decorators/is-enum'; +export * from './decorators/is-nested'; +export * from './decorators/is-number'; +export * from './decorators/is-object'; +export * from './decorators/is-string';