Skip to content

Instantly share code, notes, and snippets.

@VinceOPS
Last active January 10, 2019 15:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save VinceOPS/17e9b51c1b840c4cb31aee7ee0fa8603 to your computer and use it in GitHub Desktop.
Save VinceOPS/17e9b51c1b840c4cb31aee7ee0fa8603 to your computer and use it in GitHub Desktop.
Inherit validation metadata of another property, using class-validator
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]);
}
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