Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exclude/Expose decorator for properties in class inheritance #13

Open
bagbyte opened this issue Jun 8, 2019 · 7 comments
Open

Exclude/Expose decorator for properties in class inheritance #13

bagbyte opened this issue Jun 8, 2019 · 7 comments

Comments

@bagbyte
Copy link

bagbyte commented Jun 8, 2019

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?

@epiphone
Copy link
Owner

epiphone commented Jun 10, 2019

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.

@bagbyte
Copy link
Author

bagbyte commented Nov 29, 2019

@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?

@epiphone
Copy link
Owner

epiphone commented Dec 2, 2019

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!

@bagbyte
Copy link
Author

bagbyte commented Dec 4, 2019

@epiphone few feedback:

  1. validation groups are not supported by class-validator-jsonschema so the swagger file will not be effected
  2. implementing Pick will require the declaration of each property
  3. 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']) {}

@epiphone
Copy link
Owner

epiphone commented Dec 5, 2019

Cool, nice work! That seems really handy.

If you've the time to open a PR I'd be happy to merge it ;)

@bagbyte
Copy link
Author

bagbyte commented Dec 5, 2019

I forgot to mention this requires keyofStringsOnly set to true, not sure this should be part of class-validator-jsonschema or class-validator tho

@epiphone
Copy link
Owner

epiphone commented Dec 6, 2019

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 }?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants