class-validator-jsonschema
class-validator-jsonschema copied to clipboard
Exclude/Expose decorator for properties in class inheritance
I have 2 projects sharing some entities, and I'm trying to find a way to keep the validation consistent through all my application, and if possible, reuse part of the code instead of copying/pasting it.
My example
Classes needed
// This class describes the database table
class UserDB {
id: string; // generated by DB when a new record is added
email: string; // NOT NULL
age?: number;
firstName?: string;
lastName?: string;
}
// This class describes all fields validations
class User {
@IsUUID("4") @JSONSchema({ description: `User's ID` })
id: string;
@IsEmail() @JSONSchema({ description: `User's email` })
email: string;
@IsOption() @IsInt() @IsPositive() @Min(18) @JSONSchema({ description: `User's age` })
age?: number;
@IsOption() @MinLength(3) @JSONSchema({ description: `User's first name` })
firstName?: string;
@IsOption() @MinLength(5) @JSONSchema({ description: `User's last name` })
lastName?: string;
}
Create User API schemas
class CreateUserRequest {
@IsEmail() @JSONSchema({ description: `User's email` })
email: string;
@IsOption() @IsInt() @IsPositive() @Min(18) @JSONSchema({ description: `User's age` })
age?: number;
@IsOption() @MinLength(3) @JSONSchema({ description: `User's first name` })
firstName?: string;
@IsOption() @MinLength(5) @JSONSchema({ description: `User's last name` })
lastName?: string;
}
class CreateUserResponse extends User { }
Change Email API schemas
class ChangeEmailRequest {
@IsEmail() @JSONSchema({ description: `New email address` })
email: string;
}
class ChangeEmailResponse extends User { }
As we can see, we keep copying/pasting all the validations and descriptions from class to class, so I'm trying to find a better way to reuse the code so that it is also easier to maintain.
Solution 1
I create a common class containing the "base" properties.
class UserCommon {
@IsEmail() @JSONSchema({ description: `User's email` })
email: string;
@IsOption() @IsInt() @IsPositive() @Min(18) @JSONSchema({ description: `User's age` })
age?: number;
@IsOption() @MinLength(3) @JSONSchema({ description: `User's first name` })
firstName?: string;
@IsOption() @MinLength(5) @JSONSchema({ description: `User's last name` })
lastName?: string;
}
class User extends UserCommon {
@IsUUID("4") @JSONSchema({ description: `User's ID` })
id: string;
}
And then try to reuse the "base" class whenever possible
class CreateUserRequest extends UserCommon {}
class CreateUserResponse extends User {}
class ChangeEmailRequest {
@IsEmail() @JSONSchema({ description: `User's email` })
email: string;
}
class ChangeEmailResponse extends User {}
Solution 2
Create a base class describing all the fields with their validations.
class User {
@IsUUID("4") @JSONSchema({ description: `User's ID` })
id: string;
@IsEmail() @JSONSchema({ description: `User's email` })
email: string;
@IsOption() @IsInt() @IsPositive() @Min(18) @JSONSchema({ description: `User's age` })
age?: number;
@IsOption() @MinLength(3) @JSONSchema({ description: `User's first name` })
firstName?: string;
@IsOption() @MinLength(5) @JSONSchema({ description: `User's last name` })
lastName?: string;
}
And then extend it excluding or exposing fields
class CreateUserRequest extends User {
@Exclude()
id: string;
}
class CreateUserResponse extends User {}
class ChangeEmailRequest {
@Expose()
email: string;
}
class ChangeEmailResponse extends User {}
Solution 1 can be already implemented, even tho it will be hard to isolate the "common" properties when the app starts becoming big. i.e. if I introduce an UpdateUser API, probably I want to keep the email out of it, so I have to remove the email from the UserCommon class.
Solution 2 would be really flexible but I guess it is not supported currently by this library, right? Any chance to get this implemented?
Do you have any feedback? or any smarter way to achieve this result?
Hi, that's an interesting use case. Thanks for the thorough explanation!
I see how solution 1 might be hard to maintain in the long run. And yes, we would need to add support for the Exclude/Expose decorators for solution 2 to work out. Glad to review a PR!
Another solution (let's call it solution 3) off the top of my head would be isolating User property validators from the actual classes, something like
const IsUserId = () => (...args: [object, string]) => {
IsUUID("4")(...args)
JSONSchema({ description: `User's ID` })(...args)
}
// although we could write a neater function for composing decorators...
and then building validation classes out of these decorators:
class GetUserRequest {
@IsUserId()
id: string;
}
maybe ~~extending from a base class~~ implementing a subset of User's interface for type safety:
class GetUserRequest implements Pick<User, 'id'> {
@IsUserId()
id: string;
}
Might turn out a bit verbose but at least the validators are only defined once and reusable.
@epiphone the solution 3 looks cool, but when I try to create a class extending Pick, I get an error: TS2693: Pick only refers to a type, but is being used as a value here.
If I just create a type: type GetUserRequest = Pick<User, 'id'>
Then the swagger file I'm getting leaks of this type, the schema is not generated for it.
Did you find any solution for that? Am I doing something wrong?
Oh my bad, GetUserRequest should implement the Picked interface, not extend it. I'll edit the above comment.
For yet another approach, class-validator's validation groups might be worth checking out. Of course they're not supported by this library yet - implementing support should be doable though!
@epiphone few feedback:
- validation groups are not supported by
class-validator-jsonschemaso the swagger file will not be effected - implementing
Pickwill require the declaration of each property - you still need to decorate each property
I've come out with a new solution much lighter.
// inheritValidations.ts
import { getFromContainer, MetadataStorage } from 'class-validator';
import { ValidationMetadata } from 'class-validator/metadata/ValidationMetadata';
export type ClassConstructor<T> = new () => T;
function strEnum<T extends string>(o: T[]): { [P in T]: P } {
return o.reduce((res, key) => {
res[key] = key;
return res;
}, Object.create(null));
}
export function inheritValidations<T, P extends keyof T>(NewClass: Function,
BaseClass: ClassConstructor<T>,
properties: P[]) {
const propertiesObject = strEnum(properties);
getFromContainer(MetadataStorage).getTargetValidationMetadatas(BaseClass, null)
.filter(md => properties.includes(md.propertyName as any))
.forEach((md) => {
const validationMetadata = { ...md };
validationMetadata.target = NewClass;
getFromContainer(MetadataStorage).addValidationMetadata(new ValidationMetadata(validationMetadata));
});
return NewClass as new (...args: any) => Pick<T, keyof typeof propertiesObject>;
}
Now we can define all the validations on the User class, and create a new class using the validation already defined, specifying the list of properties to inherit (with their validations settings).
// GetUserRequest.ts
export class GetUserRequest extends inheritValidations(class GetUserRequest {}, User, ['id']) {}
Cool, nice work! That seems really handy.
If you've the time to open a PR I'd be happy to merge it ;)
I forgot to mention this requires keyofStringsOnly set to true, not sure this should be part of class-validator-jsonschema or class-validator tho
Doesn't it work without keyOfStringsOnly: true if we extend strEnums type to something like function strEnum<T extends string | symbol | number>(o: T[]): { [P in T]: P }?