Last active
January 10, 2019 15:36
-
-
Save VinceOPS/17e9b51c1b840c4cb31aee7ee0fa8603 to your computer and use it in GitHub Desktop.
Inherit validation metadata of another property, using class-validator
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { getFromContainer, IsDateString, IsEmail, IsNumber, IsOptional, IsString, Max, MaxLength, MetadataStorage, validate } from 'class-validator'; | |
import { ValidationMetadata } from 'class-validator/metadata/ValidationMetadata'; | |
import _ from 'lodash'; | |
import { InheritValidation } from './inherit-validation.dtodep.decorator'; | |
/** | |
* Used as a base for validation, in order for partial classes | |
* to pick validation metadatas, property by property. | |
*/ | |
class Dto { | |
@IsDateString() | |
readonly createdAt: string; | |
@IsEmail() | |
@IsOptional() | |
readonly email: string; | |
@IsNumber() | |
@Max(999) | |
readonly id: number; | |
@IsString() | |
@MaxLength(10) | |
readonly name: number; | |
} | |
const validationsCount = 7; | |
describe('@InheritValidation', () => { | |
let dtoMetaDatas: ValidationMetadata[]; | |
beforeEach(() => { | |
dtoMetaDatas = getMetadatasFrom(Dto); | |
expect(dtoMetaDatas).toHaveLength(validationsCount); | |
}); | |
it('does not modify metadatas of the source class', () => { | |
const dtoMetadatasNow = getMetadatasFrom(Dto); | |
expect(dtoMetaDatas).toEqual(dtoMetadatasNow); | |
}); | |
it('does a deep copy of validation metadatas', () => { | |
class SubDto { | |
@InheritValidation(Dto, 'name') | |
readonly name: string; | |
} | |
const dtoMetadatas = getMetadatasFrom(Dto, 'name'); | |
const subMetadatas = getMetadatasFrom(SubDto, 'name'); | |
const areEqual = areMetadatasEqual( | |
[dtoMetadatas, subMetadatas], | |
// `propertyName` did not change ("name" => "name"), but `target` did: remove it | |
['target'], | |
); | |
// with `target` removed, this is a perfect copy | |
expect(areEqual).toBe(true); | |
}); | |
it('uses destination property name as a source property name if none is given', () => { | |
class SubDto { | |
@InheritValidation(Dto) | |
readonly name: string; | |
} | |
const dtoMetadatas = getMetadatasFrom(Dto, 'name'); | |
const subMetadatas = getMetadatasFrom(SubDto, 'name'); | |
const areEqual = areMetadatasEqual([dtoMetadatas, subMetadatas], ['target']); | |
expect(areEqual).toBe(true); | |
}); | |
it('allows inheriting validation metadatas with a different property name', () => { | |
class SubDto { | |
@InheritValidation(Dto, 'name') | |
readonly nickname: string; | |
} | |
const dtoMetadatas = getMetadatasFrom(Dto, 'name'); | |
const subMetadatas = getMetadatasFrom(SubDto, 'nickname'); | |
const areEqual = areMetadatasEqual( | |
[dtoMetadatas, subMetadatas], | |
// `propertyName` and `target` changed: remove them before checking equality | |
['propertyName', 'target'], | |
); | |
expect(areEqual).toBe(true); | |
}); | |
it('can be used on multiple properties', () => { | |
class SubDto { | |
@InheritValidation(Dto) | |
readonly id: number; | |
@InheritValidation(Dto) | |
readonly name: string; | |
} | |
const dtoMetadatas = _.concat( | |
// only get metadatas from fields used by SubDto | |
getMetadatasFrom(Dto, 'id'), | |
getMetadatasFrom(Dto, 'name'), | |
); | |
const subMetadatas = getMetadatasFrom(SubDto); | |
const areEqual = areMetadatasEqual([dtoMetadatas, subMetadatas], ['target']); | |
expect(areEqual).toBe(true); | |
}); | |
it('uses the inherited metadatas for objects validation (IsString, MaxLength)', async () => { | |
class SubDto { | |
@InheritValidation(Dto) | |
readonly name: string; | |
constructor(name: string) { | |
this.name = name; | |
} | |
} | |
const validSubDto = new SubDto('Mike'); | |
expect(await validate(validSubDto)).toHaveLength(0); | |
const invalidSubDto = new SubDto('way_too_long_name'); | |
const errors = await validate(invalidSubDto); | |
expect(errors).toHaveLength(1); | |
expect(errors[0].constraints).toHaveProperty('maxLength'); | |
}); | |
it('uses the inherited metadatas for objects validation (IsDateString)', async () => { | |
class SubDto { | |
@InheritValidation(Dto) | |
readonly createdAt: string; | |
constructor(createdAt: string) { | |
this.createdAt = createdAt; | |
} | |
} | |
const validSubDto = new SubDto('2019-01-10T15:29:10.783Z'); | |
expect(await validate(validSubDto)).toHaveLength(0); | |
const invalidSubDto = new SubDto('Invalid Date'); | |
const errors = await validate(invalidSubDto); | |
expect(errors).toHaveLength(1); | |
expect(errors[0].constraints).toHaveProperty('isDateString'); | |
}); | |
}); | |
/** | |
* Use `class-validator`'s `MetadataStorage` to get the `ValidationMetadata`s | |
* of a given class, or (more specific) one of its property. | |
* | |
* @param fromClass Class to get `ValidationMetadata`s from. | |
* @param property Source property (if none is given, get metadatas from all properties). | |
* | |
* @return {ValidationMetadata[]} Target metadatas. | |
*/ | |
function getMetadatasFrom(fromClass: new () => object, property?: string): ValidationMetadata[] { | |
const metadataStorage = getFromContainer(MetadataStorage); | |
const metadatas = _.cloneDeep(metadataStorage.getTargetValidationMetadatas(fromClass, undefined as any)); | |
if (!property) { | |
return metadatas; | |
} | |
return metadatas.filter((vm: ValidationMetadata) => vm.propertyName === property); | |
} | |
/** | |
* Determine whether two collections of `ValidationMetadata`s are | |
* the same, eventually after having removed a few fields (which are | |
* known to have been changed by design). | |
* | |
* @param metaDataCollections Array of 2 `ValidationMetadata[]` to be compared. | |
* @param withoutFields Fields to be removed from the metadatas before comparing them. | |
* | |
* @return {boolean} `true` if both collections are equal. | |
*/ | |
function areMetadatasEqual(metaDataCollections: ValidationMetadata[][], withoutFields: string[]): boolean { | |
if (metaDataCollections.length !== 2) { | |
throw new TypeError('Misuse of metadatasAreEqual'); | |
} | |
_.each(withoutFields, field => { | |
_.each(metaDataCollections, metadatas => { | |
_.each(metadatas, md => _.unset(md, field)); | |
}); | |
}); | |
return _.isEqual(metaDataCollections[0], metaDataCollections[1]); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { getFromContainer, MetadataStorage } from 'class-validator'; | |
import _ from 'lodash'; | |
/** | |
* Allow copying validation metadatas set by `class-validator` from | |
* a given Class property to an other. Copied `ValidationMetadata`s | |
* will have their `target` and `propertyName` changed according to | |
* the decorated class and property. | |
* | |
* @param fromClass Class to inherit validation metadatas from. | |
* @param fromProperty Name of the target property (default to decorated property). | |
* | |
* @return {PropertyDecorator} Responsible for copying and registering `ValidationMetada`s. | |
* | |
* @example | |
* class SubDto { | |
* @InheritValidation(Dto) | |
* readonly id: number; | |
* | |
* @InheritValidation(Dto, 'name') | |
* readonly firstName: string; | |
* | |
* @InheritValidation(Dto, 'name') | |
* readonly lastName: string; | |
* } | |
*/ | |
export function InheritValidation<T>(fromClass: new (...args: any[]) => T, fromProperty?: keyof T): PropertyDecorator { | |
const metadataStorage = getFromContainer(MetadataStorage); | |
const validationMetadatas = metadataStorage.getTargetValidationMetadatas(fromClass, typeof fromClass); | |
/** | |
* Change the `target` and `propertyName` of each `ValidationMetaData` | |
* and add it to `MetadataStorage`. Thus, `class-validator` uses it | |
* during validation. | |
* | |
* @param toClass Class owning the decorated property. | |
* @param toProperty Name of the decorated property. | |
*/ | |
return (toClass: object, toProperty: any) => { | |
const toPropertyName = toProperty as string; | |
const sourceProperty = fromProperty || toProperty; | |
const metadatasCopy = _.cloneDeep(validationMetadatas.filter(vm => vm.target === fromClass && vm.propertyName === sourceProperty)); | |
metadatasCopy.forEach(vm => { | |
vm.target = toClass.constructor; | |
vm.propertyName = toPropertyName; | |
metadataStorage.addValidationMetadata(vm); | |
}); | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment