From 7892f2682930ee9039c126b527ed58ee4ed2a3b8 Mon Sep 17 00:00:00 2001 From: Rubens Nascimento Date: Thu, 24 Feb 2022 09:18:06 -0300 Subject: [PATCH 1/3] feat: add AllowIf decorator --- src/decorator/common/Allow.ts | 1 + src/decorator/common/AllowIf.ts | 24 +++++++++++++ src/decorator/decorators.ts | 1 + src/validation/ValidationExecutor.ts | 17 ++++++++- test/functional/whitelist-validation.spec.ts | 38 +++++++++++++++++++- 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/decorator/common/AllowIf.ts diff --git a/src/decorator/common/Allow.ts b/src/decorator/common/Allow.ts index 943722ec8c..3fc5645a9a 100644 --- a/src/decorator/common/Allow.ts +++ b/src/decorator/common/Allow.ts @@ -13,6 +13,7 @@ export function Allow(validationOptions?: ValidationOptions): PropertyDecorator type: ValidationTypes.WHITELIST, target: object.constructor, propertyName: propertyName, + constraints: [], validationOptions: validationOptions, }; getMetadataStorage().addValidationMetadata(new ValidationMetadata(args)); diff --git a/src/decorator/common/AllowIf.ts b/src/decorator/common/AllowIf.ts new file mode 100644 index 0000000000..a0594c0af9 --- /dev/null +++ b/src/decorator/common/AllowIf.ts @@ -0,0 +1,24 @@ +import { ValidationOptions } from '../ValidationOptions'; +import { ValidationMetadataArgs } from '../../metadata/ValidationMetadataArgs'; +import { ValidationTypes } from '../../validation/ValidationTypes'; +import { ValidationMetadata } from '../../metadata/ValidationMetadata'; +import { getMetadataStorage } from '../../metadata/MetadataStorage'; + +/** + * If object has both allowed and not allowed properties a validation error will be thrown. + */ +export function AllowIf( + condition: (object: any) => boolean, + validationOptions?: ValidationOptions +): PropertyDecorator { + return function (object: object, propertyName: string): void { + const args: ValidationMetadataArgs = { + type: ValidationTypes.WHITELIST, + target: object.constructor, + propertyName: propertyName, + constraints: [condition], + validationOptions: validationOptions, + }; + getMetadataStorage().addValidationMetadata(new ValidationMetadata(args)); + }; +} diff --git a/src/decorator/decorators.ts b/src/decorator/decorators.ts index d449e9301a..9e04a94e91 100644 --- a/src/decorator/decorators.ts +++ b/src/decorator/decorators.ts @@ -7,6 +7,7 @@ // ------------------------------------------------------------------------- export * from './common/Allow'; +export * from './common/AllowIf'; export * from './common/IsDefined'; export * from './common/IsOptional'; export * from './common/Validate'; diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts index 7b870dcd11..083e535fd0 100644 --- a/src/validation/ValidationExecutor.ts +++ b/src/validation/ValidationExecutor.ts @@ -124,9 +124,18 @@ export class ValidationExecutor { const notAllowedProperties: string[] = []; Object.keys(object).forEach(propertyName => { + const metadatas = groupedMetadatas[propertyName]; // does this property have no metadata? - if (!groupedMetadatas[propertyName] || groupedMetadatas[propertyName].length === 0) + if (!metadatas || metadatas.length === 0) { notAllowedProperties.push(propertyName); + return; + } + // does this property has condition to allow? + const conditionalWhitelistMetadatas = metadatas.filter(metadata => metadata.type === ValidationTypes.WHITELIST && metadata.constraints.length > 0); + const canAllow = this.conditionalWhitelist(object, conditionalWhitelistMetadatas); + if (!canAllow) { + notAllowedProperties.push(propertyName); + } }); if (notAllowedProperties.length > 0) { @@ -242,6 +251,12 @@ export class ValidationExecutor { return validationError; } + private conditionalWhitelist(object: object, metadatas: ValidationMetadata[]): ValidationMetadata[] { + return metadatas + .map(metadata => metadata.constraints[0](object)) + .reduce((resultA, resultB) => resultA && resultB, true); + } + private conditionalValidations(object: object, value: any, metadatas: ValidationMetadata[]): ValidationMetadata[] { return metadatas .map(metadata => metadata.constraints[0](object, value)) diff --git a/test/functional/whitelist-validation.spec.ts b/test/functional/whitelist-validation.spec.ts index 2667bcb6a9..1d2c58ec67 100644 --- a/test/functional/whitelist-validation.spec.ts +++ b/test/functional/whitelist-validation.spec.ts @@ -1,4 +1,4 @@ -import { Allow, IsDefined, IsOptional, Min } from '../../src/decorator/decorators'; +import { Allow, AllowIf, IsDefined, IsOptional, Min } from '../../src/decorator/decorators'; import { Validator } from '../../src/validation/Validator'; import { ValidationTypes } from '../../src'; @@ -45,6 +45,42 @@ describe('whitelist validation', () => { }); }); + it("should'n be able to whitelist with @AllowIf when condition return false", () => { + class MyClass { + @AllowIf(o => false) + views: number; + } + + const model: any = new MyClass(); + + model.views = 420; + model.unallowedProperty = 'non-whitelisted'; + + return validator.validate(model, { whitelist: true }).then(errors => { + expect(errors.length).toEqual(0); + expect(model.unallowedProperty).toBeUndefined(); + expect(model.views).toBeUndefined(); + }); + }); + + it('should be able to whitelist with @AllowIf when condition return true', () => { + class MyClass { + @AllowIf(o => true) + views: number; + } + + const model: any = new MyClass(); + + model.views = 420; + model.unallowedProperty = 'non-whitelisted'; + + return validator.validate(model, { whitelist: true }).then(errors => { + expect(errors.length).toEqual(0); + expect(model.unallowedProperty).toBeUndefined(); + expect(model.views).toEqual(420); + }); + }); + it('should throw an error when forbidNonWhitelisted flag is set', () => { class MyPayload { /** From b0d715000dad8f96478b748f603e531ad67b141a Mon Sep 17 00:00:00 2001 From: Rubens Nascimento Date: Thu, 24 Feb 2022 10:05:12 -0300 Subject: [PATCH 2/3] docs: add documentation to AllowIf decorator --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index a6f88d3452..e49f58d824 100644 --- a/README.md +++ b/README.md @@ -469,6 +469,37 @@ validate(post).then(errors => { }); ``` +If you would want a property conditionally whitelisted you can use the @AllowIf decorator: + +```typescript +import {validate, Allow, AllowIf, Min} from "class-validator"; + +export class Post { + + @Allow() + title: string; + + @Min(0) + views: number; + + @AllowIf(post => post.views > 10) + whitelistedProperty: number; +} + +let post = new Post(); +post.title = 'Hello world!'; +post.views = 420; + +post.whitelistedProperty = 69; +(post as any).nonWhitelistedProperty = "something"; + +validate(post).then(errors => { + // post.whitelistedProperty is defined + // (post as any).nonWhitelistedProperty is not defined + ... +}); +``` + If you would rather to have an error thrown when any non-whitelisted properties are present, pass another flag to `validate` method: From 0ad60a857526d291c6384d4a0b5b7d73df3f8fd1 Mon Sep 17 00:00:00 2001 From: Thomas Date: Mon, 14 Aug 2023 10:23:58 -0700 Subject: [PATCH 3/3] feat: Change AllowIf to support forbidNonWhitelisted --- src/validation/ValidationExecutor.ts | 8 +- test/functional/whitelist-validation.spec.ts | 78 ++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts index 083e535fd0..13a46074ad 100644 --- a/src/validation/ValidationExecutor.ts +++ b/src/validation/ValidationExecutor.ts @@ -132,7 +132,7 @@ export class ValidationExecutor { } // does this property has condition to allow? const conditionalWhitelistMetadatas = metadatas.filter(metadata => metadata.type === ValidationTypes.WHITELIST && metadata.constraints.length > 0); - const canAllow = this.conditionalWhitelist(object, conditionalWhitelistMetadatas); + const canAllow = this.conditionalWhitelist(object, propertyName, conditionalWhitelistMetadatas); if (!canAllow) { notAllowedProperties.push(propertyName); } @@ -251,8 +251,10 @@ export class ValidationExecutor { return validationError; } - private conditionalWhitelist(object: object, metadatas: ValidationMetadata[]): ValidationMetadata[] { - return metadatas + private conditionalWhitelist(object: any, propertyName: string, metadatas: ValidationMetadata[]): ValidationMetadata[] { + return !Object.keys(object).includes(propertyName) || + object[propertyName] === undefined || + metadatas .map(metadata => metadata.constraints[0](object)) .reduce((resultA, resultB) => resultA && resultB, true); } diff --git a/test/functional/whitelist-validation.spec.ts b/test/functional/whitelist-validation.spec.ts index 1d2c58ec67..414e45aaa5 100644 --- a/test/functional/whitelist-validation.spec.ts +++ b/test/functional/whitelist-validation.spec.ts @@ -81,6 +81,84 @@ describe('whitelist validation', () => { }); }); + it('should not throw an error when forbidNonWhitelisted flag is set and @AllowIf is true', () => { + class MyPayload { + /** + * Since forbidUnknownValues defaults to true, we must add a property to + * register the class in the metadata storage. Otherwise the unknown value check + * would take priority (first check) and exit without running the whitelist logic. + */ + @IsOptional() + propertyToRegisterClass: string; + + @AllowIf(o => true) + conditionalDecorated: string; + + constructor(conditionalDecorated: string) { + this.conditionalDecorated = conditionalDecorated; + } + } + + const instance = new MyPayload('conditional-whitelisted'); + + return validator.validate(instance, { whitelist: true, forbidNonWhitelisted: true }).then(errors => { + expect(errors.length).toEqual(0); + expect(instance.conditionalDecorated).toEqual('conditional-whitelisted') + }); + }); + + it('should throw an error when forbidNonWhitelisted flag is set and @AllowIf is false', () => { + class MyPayload { + /** + * Since forbidUnknownValues defaults to true, we must add a property to + * register the class in the metadata storage. Otherwise the unknown value check + * would take priority (first check) and exit without running the whitelist logic. + */ + @IsOptional() + propertyToRegisterClass: string; + + @AllowIf(o => false) + nonDecorated: string; + + constructor(nonDecorated: string) { + this.nonDecorated = nonDecorated; + } + } + + const instance = new MyPayload('non-whitelisted'); + + return validator.validate(instance, { whitelist: true, forbidNonWhitelisted: true }).then(errors => { + expect(errors.length).toEqual(1); + expect(errors[0].target).toEqual(instance); + expect(errors[0].property).toEqual('nonDecorated'); + expect(errors[0].constraints).toHaveProperty(ValidationTypes.WHITELIST); + expect(() => errors[0].toString()).not.toThrow(); + }); + }); + + it('should not throw an error when forbidNonWhitelisted flag is set and @AllowIf is false and property is not set', () => { + class MyPayload { + /** + * Since forbidUnknownValues defaults to true, we must add a property to + * register the class in the metadata storage. Otherwise the unknown value check + * would take priority (first check) and exit without running the whitelist logic. + */ + @IsOptional() + propertyToRegisterClass: string; + + @AllowIf(o => false) + conditionalDecorated: string; + + } + + const instance = new MyPayload(); + + return validator.validate(instance, { whitelist: true, forbidNonWhitelisted: true }).then(errors => { + expect(errors.length).toEqual(0); + expect(instance.conditionalDecorated).toBeUndefined(); + }); + }); + it('should throw an error when forbidNonWhitelisted flag is set', () => { class MyPayload { /**