NestJS-Class Validator

jaegeunsong97·2023년 11월 25일
0

NestJS

목록 보기
18/37
post-thumbnail
지금부터는 변경의 완성본 위주로 업로드가 됩니다.

🖊️class Validator와 DTO

@Post()
@UseGuards(AccessTokenGuard)
postPosts(
  	@User('id') userId: number, // <- @Request() req: any <- @Body('authorId') authorId: number,
  	@Body('title') title: string,
  	@Body('content') content: string,
) {
    return this.postsService.createPost(
      	userId, title, content
    );
}

위의 코드를 보면 Body에 title과 content가 있습니다. 이 2개의 Body는 다른 곳에서도 받을 가능성이 높습니다. 또한 string 값은 에러가 발생할 확률이 높기 때문에 좀 더 효율적으로 관리하는 방법을 알아 보겠습니다.

nest cli를 사용해서 다음과 같이 입력해줍니다. 이제부터 우리는 2개의 Body값을 1개의 클래스로 묶어서 관리를 할 것입니다. 그리고 이런 형태의 클래스는 우리는 DTO(data transfer object)라고 부릅니다.

yarn add class-validator class-transformer

post의 dto폴더을 만들어서 코드를 만들어 줍니다. 참고로 DTO는 한곳에서만 사용되는 것이 아니라 다른 API에서도 사용이 가능합니다.

  • posts/dto/create-post.dto.ts
export class CreatePostDto {

     title: string;

     content: string;
}

그리고 controller와 service를 수정합니다.

  • post.controller.ts
@Post()
@UseGuards(AccessTokenGuard)
postPosts(
    @User('id') userId: number, 
    @Body() body: CreatePostDto, // 변경
) {
    return this.postsService.createPost(userId, body); // 변경
}
  • post.service.ts
async createPost(authorId: number, postDto: CreatePostDto) { // 변경
    const post = this.postsRepository.create({ 
        author: 
          	id: authorId,
        },
        ...postDto, // 변경
        likeCount: 0,
        commentCount: 0,
    });

    const newPost = await this.postsRepository.save(post);
    return newPost;
}

자 CreatePostDto를 생성한 이유는 우리가 검증(validation)하기 위해서입니다. 하지만 CreatePostDto를 보면 아무것도 검증할 것이 없습니다. 따라서 검증할 어노테이션을 가져옵니다. 따라서 title과 content는 string이 아니면 무조건 에러를 던지게 됩니다.

  • posts/dto/create-post.dto.ts
import { IsString } from "class-validator";

export class CreatePostDto {
	
  	@IsString() // 변경
    title: string;
	
  	@IsString() // 변경
    content: string;
}

이제 이 validator들을 전역적으로 관리하고 사용하기 위해서 main.ts에 global하게 등록합니다.

  • main.ts
async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    // nest.js에 전반적으로 작용할 Pipe
    // new ValidationPipe(): 
    //  1) 모든 클래스 validator의 @IsString()같은 어노테이션들이 따로 module에 적용하거나 추가하지 않아도 app 전반적으로 validator를 사용할 수 있게됨
    //  2) global
    //  3) validator들이 실행되도록 만들어주는 코드 ex) @IsString(), @NotNull..
    app.useGlobalPipes(new ValidationPipe())
    await app.listen(3000);
}
bootstrap();

포스트맨으로 테스트를 해봅시다.

{
    "message": [
        "title must be a string"
    ],
    "error": "Bad Request",
    "statusCode": 400
}
  • 참고
github.com/typestack/class-validator 이동 -> validator-decorators 클릭
nest.js에 관련된 모든 데코레이터들의 목록과 리스트

🖊️Class Validator 에러 메세지 변경

{
    "message": [
        "title must be a string"
    ],
    "error": "Bad Request",
    "statusCode": 400
}

영어로 나왔던 에러메세지를 직접 커스텀 작성을 해보겠습니다. 그리고 포스트맨으로 테스트를 해보겠습니다.

  • posts/dto/create-post.dto.ts
export class CreatePostDto {

     @IsString({
          message: 'title은 string 타입을 입력 해줘야합니다.'
     })
     title: string;

     @IsString({
          message: 'content는 string 타입을 입력 해줘야합니다.'
     }) 
     content: string;
}

{
    "message": [
        "title은 string 타입을 입력 해줘야합니다."
    ],
    "error": "Bad Request",
    "statusCode": 400
}

{
    "message": [
        "title은 string 타입을 입력 해줘야합니다."
    ],
    "error": "Bad Request",
    "statusCode": 400
}

{
    "message": [
        "title은 string 타입을 입력 해줘야합니다.",
        "content는 string 타입을 입력 해줘야합니다."
    ],
    "error": "Bad Request",
    "statusCode": 400
}

🖊️PickType 활용

클래스를 사용하면 OOP를 활용할 수 있는 가장 큰 장점이 있습니다. 따라서 CreatePostDto 클래스를 좀 더 효율적으로 만들어 보겠습니다. posts.entity.ts로 이동합니다.

CreatePostDto에 title과 content가 있고, posts의 엔티티에도 title과 content가 존재합니다. 중복이 되는 겁니다. 따라서 상속을 이용해서 해결해 보겠습니다.

먼저 validator들을 잘라내서 posts.entity.ts로 이동시킵니다.

  • posts/dto/create-post.dto.ts
export class CreatePostDto {

     title: string;
  
     content: string;
}
  • posts.entity.ts
@Entity()
export class PostsModel extends BaseModel{
  
     @ManyToOne(() => UsersModel, (user) => user.posts, {
          nullable: false,
     })
     author: UsersModel;

     @Column()
     @IsString({
          message: 'title은 string 타입을 입력 해줘야합니다.'
     })
     title: string;

     @Column()
     @IsString({
          message: 'content는 string 타입을 입력 해줘야합니다.'
     }) 
     content: string;

     @Column()
     likeCount: number;

     @Column()
     commentCount: number;
}

만약 CreatePostDto가 PostsModel을 extend하면 title, content 뿐만아니라 다른 값도 전부 받게 됩니다. 이때 Typescript 유틸리티를 이용해서 해결하겠습니다.

  • posts/dto/create-post.dto.ts
import { PickType } from "@nestjs/mapped-types";
import { PostsModel } from "../entities/posts.entity"

/**
 * TS에서 사용한 Utilities
 * - Pick, Omit, Partial -> Type 반환, Generic
 * - PickType, OmitType, PartialType -> 값을 반환, function
 * 
 * extends:는 Type을 상속받지 못하고 값을 상속받아야한다! (중요!)
 * PickType(PostsModel, ['title', 'content']): title과 content만 골라서 상속받기
 */
export class CreatePostDto extends PickType(PostsModel, ['title', 'content']){}

포스트맨으로 테스트를 해보겠습니다.

{
    "message": [
        "content는 string 타입을 입력 해줘야합니다."
    ],
    "error": "Bad Request",
    "statusCode": 400
}

{
    "message": [
        "content는 string 타입을 입력 해줘야합니다.",
        "content는 string 타입을 입력 해줘야합니다."
    ],
    "error": "Bad Request",
    "statusCode": 400
}

🖊️IsOptional Annotation

이제는 업데이트를 하는 API putPost를 작업하겠습니다. 따라서 기존의 컨트롤러 코드에서 DTO를 사용하는 코드로 변경하겠습니다. 변경과정은 앞서본 postPost와 비슷하기 때문에 결과만 보여드리도록 하겠습니다. 먼저 UpdatePostDto를 만듭니다.

  • posts/dto/update-post.dto.ts
export class UpdatePostDto extends CreatePostDto{}

이렇게 UpdatePostDto가 CreatePostDto extend를 하면 안됩니다. 왜냐하면 업데이트는 값이 들어올 수도 있고, 안들어 올 수도 있기 때문입니다. 심지어 @IsString()의 경우는 무조건 적으로 값을 받아야 통과가 됩니다. 따라서 바꿔주겠습니다.

import { PartialType } from '@nestjs/mapped-types';
import { CreatePostDto } from './create-post.dto';
import { IsOptional, IsString } from 'class-validator';

// extends PickType(PostsModel, ['title', 'content']) 동일
// Partial: 전부다 require가 아닌 optional로 만들어주는 것 -> 즉 선택적 상속
export class UpdatePostDto extends PartialType(CreatePostDto){
    // @IsOptional()을 사용하면 Optional 값으로 validation할 수 있다.

    @IsString()
    @IsOptional()
    title?: string;

    @IsString()
    @IsOptional()
    content?: string;
}

여기서 주목해야할 부분은 UpdatePostDto가 CreatePostDto를 상속받은 것입니다. 하지만 Pick이 아닌 Partial로 했습니다. 차이점은 Pick의 경우 선택한 것은 무조건 받아야하지만 Partial은 Optaional이기 때문입니다. 따라서 title과 content 뒤에 ?가 붙게 되는 것입니다. 이제 컨트롤러를 바꿔주겠습니다.

  • post.controller.ts
@Put(':id')
putPost(
    @Param('id', ParseIntPipe) id: number, 
    @Body() body: UpdatePostDto, // 변경
) {
    return this.postsService.updatePost(id, body); // 변경
}
  • post.service.ts
async updatePost(authorId: number, postDto: UpdatePostDto) { // 변경
    const { title, content } = postDto; // 변경
    const post = await this.postsRepository.findOne({
        where: {
          	id: authorId,
        },
    });
    if (!post) throw new NotFoundException();
    if (title) post.title = title;
    if (content) post.content = content;

    const newPost = await this.postsRepository.save(post);
    return newPost;
}

포스트맨으로 테스트를 하겠습니다.

{
    "id": 3,
    "updatedAt": "2024-01-27T20:40:26.982Z",
    "createdAt": "2024-01-27T18:00:05.730Z",
    "title": "NestJS Lecture",
    "content": "첫번쨰 content",
    "likeCount": 0,
    "commentCount": 0
}

전체를 가져오는 API를 확인해도 값이 수정되어서 나오는 것을 알 수 있습니다. 따라서 PartialType(CreatePostDto)@IsOptional(), ? 를 통해서 입력받지 않아도 에러가 나오지 않게 만들 수 있는 것을 알 수 있습니다.

!주의사항!

PUT요청이 아니고 PATCH로 바꿔줘야 합니다.

1. post 컨트롤러: @Put -> @Patch
2. post 컨트롤러: putPost -> patchPost
3. PostMan: PUT -> PATCH

🖊️length validation, email validation

  • auth/dto/register-user.dto.ts
import { PickType } from '@nestjs/mapped-types';
import { UsersModel } from 'src/users/entities/users.entity';

export class RegisterUserDto extends PickType(UsersModel, ['nickname', 'email', 'password']){}
  • users.entity.ts
@Entity()
export class UsersModel extends BaseModel{

     @Column({
          unique: true,
     })
     @IsString()
     @Length(1, 20,{
          message: '닉네임은 1~20자 사이로 입력해주세요.'
     }) // 변경
     nickname: string;

     @Column({
          unique: true
     })
     @IsString() // 변경
     @IsEmail() // 변경
     email: string;

     @Column()
     @IsString()
     @Length(3, 8,{
          message: '비밀번호는 3~8자 사이로 입력해주세요.'
     }) // 변경
     password: string;

     @Column({
          enum: Object.values(RolesEnum),
          default: RolesEnum.USER
     })
     role: RolesEnum;

     @OneToMany(() => PostsModel, (post) => post.author)
     posts: PostsModel[];
}

user의 엔티티를 다음과 같이 작성하면 auth.controller.ts과 auth.service.ts에서 변경을 할 수 있습니다.

  • auth.controller.ts
@Post('register/email')
postRegisterEmail(
  	@Body() body: RegisterUserDto, // 변경
) {
  	return this.authService.registerWithEmail(body);
}
  • auth.service.ts
async registerWithEmail(user: RegisterUserDto) { // 변경
    const hash = await bcrypt.hash(
        user.password,
        HASH_ROUNDS,
    );
    const newUser = await this.usersService.createUser({
        ...user,
        password: hash,
    });
    return this.loginUser(newUser);
}

포스트맨으로 테스트를 하겠습니다.

{
    "message": [
        "email must be an email"
    ],
    "error": "Bad Request",
    "statusCode": 400
}

{
    "message": [
        "닉네임은 1 ~ 20자 사이로 입력해주세요.",
        "email must be an email",
        "비밀번호는 3 ~ 8자 사이로 입력해주세요."
    ],
    "error": "Bad Request",
    "statusCode": 400
}

🖊️validation message 일반화

지금쯤이면 validation message를 작성하는 것이 귀찮다라고 느낄 수 있습니다. 따라서 그 귀찮은 부분을 일반화 해서 해결해 봅시다.

지금까지는 message에 string을 직접 넣기만 했습니다. 사실 message에는 override가 1개더 있습니다. 직접 함수를 작성해서 넣을 수 있다는 것입니다.

  • users.entity.ts
@Column({
    length: 20,
    unique: true,
})
@IsString()
@Length(1, 20, {
    message(args: ValidationArguments) { // 함수 작성
        /**
        * ValidationArguments의 프로퍼티
        * 
        * 1) value - 검증 되고 있는 값 (실제 입력된 값)
        * 2) constraints - 파라미터에 입력된 제한 사항들
        *    length의 경우 constraints가 2개: [1, 20]
        *    args.constraints[0] = 1, args.constraints[1] = 20 
        * 3) targetName - 검증하고 있는 클래스의 이름
        *    UsersModel
        * 4) object - 검증하고 있는 객체(잘 사용 안함)
        * 5) property - 검증 되고 있는 객체의 프로퍼티 이름
        *    nickname, email etc..
        */
        if (args.constraints.length === 2) return `${args.property}${args.constraints[0]} ~ ${args.constraints[1]}글자를 입력 해주세요.`;
        else return `${args.property}은 최소 ${args.constraints[0]}글자를 입력 해주세요.`;
    },
})
nickname: string;

message를 함수로 작성하고, 포스트맨으로 테스트를 해보겠습니다.

{
    "message": [
        "nickname은 1 ~ 20글자를 입력 해주세요.", // 주목할 부분
        "email must be an email"
    ],
    "error": "Bad Request",
    "statusCode": 400
}

entity에 등록된 message 함수를 따로 빼서 작업을 진행하고 테스트를 해보겠습니다.

  • common/validation-message/length-validation.message.ts
import { ValidationArguments } from "class-validator";

export const lengthValidationMessage = (args: ValidationArguments) => {
     /**
     * ValidationArguments의 프로퍼티
     * 
     * 1) value - 검증 되고 있는 값 (실제 입력된 값)
     * 2) constraints - 파라미터에 입력된 제한 사항들
     *    length의 경우 constraints가 2개: [1, 20]
     *    args.constraints[0] = 1, args.constraints[1] = 20 
     * 3) targetName - 검증하고 있는 클래스의 이름
     *    UsersModel
     * 4) object - 검증하고 있는 객체(잘 사용 안함)
     * 5) property - 검증 되고 있는 객체의 프로퍼티 이름
     *    nickname, email etc..
     */
     if (args.constraints.length === 2) return `${args.property}${args.constraints[0]} ~ ${args.constraints[1]}글자를 입력 해주세요.`;
     else return `${args.property}은 최소 ${args.constraints[0]}글자를 입력 해주세요.`;
}
  • users.entity.ts

다른 length를 체크하는 곳에도 적용을 합니다.

@Column({
    length: 20,
    unique: true,
})
@IsString()
@Length(1, 20, {
  	message: lengthValidationMessage // 변경
})
nickname: string;

@Column()
@IsString()
@Length(3, 8, {
  	message: lengthValidationMessage // 변경
})
password: string;

{
    "message": [
        "nickname은 1 ~ 20글자를 입력 해주세요.",
        "email must be an email",
        "password은 3 ~ 8글자를 입력 해주세요."
    ],
    "error": "Bad Request",
    "statusCode": 400
}

이렇게 작업이 되면 에러 메시지를 관리하기 더욱 편해집니다. 다른 error message도 만들도록 하겠습니다.

  • common/validation-message/string-validation.message.ts
export const stringValidationMessage = (args: ValidationArguments) => {
     return `${args.property}에 String을 입력해주세요!`;
}
  • common/validation-message/email-validation.message.ts
export const emailValidationMessage = (args: ValidationArguments) => {
     return `${args.property}에 정확한 이메일을 입력해주세요!`;
}

그리고 users.entity.ts, posts.entity.ts, UpdatePostDto에 전부 적용을 합니다. CreatePostDto와 RegisterUserDto는 상속을 받았기 때문에 할 필요가 없습니다.

  • users.entity.ts
@Entity()
export class UsersModel extends BaseModel {

    @Column({
        length: 20,
        unique: true,
    })
    @IsString({
      	message: stringValidationMessage // 변경
    })
    @Length(1, 20, {
      	message: lengthValidationMessage // 변경
    })
    nickname: string;

    @Column({
      	unique: true,
    })
    @IsString({
      	message: stringValidationMessage // 변경
    })
    @IsEmail({}, { // {}은 이메일 검증에 대한 정보
      	message: emailValidationMessage // 변경
    })
    email: string;

    @Column()
    @IsString({
      	message: stringValidationMessage // 변경
    })
    @Length(3, 8, {
      	message: lengthValidationMessage // 변경
    })
    password: string;

    @Column({
        enum: Object.values(RolesEnum),
        default: RolesEnum.USER,
    })
    role: RolesEnum;

    @OneToMany(() => PostsModel, (post) => post.author)
    posts: PostsModel[];
}
  • posts.entity.ts
@Entity()
export class PostsModel extends BaseModel {
  
     @ManyToOne(() => UsersModel, (user) => user.posts, {
          nullable: false,
     })
     author: UsersModel;

     @Column()
     @IsString({
          message: stringValidationMessage // 변경
     }) 
     title: string;

     @Column()
     @IsString({
          message: stringValidationMessage // 변경
     }) 
     content: string;

     @Column()
     likeCount: number;

     @Column()
     commentCount: number;
}
  • posts/dto/update-post.dto.ts
export class UpdatePostDto extends PartialType(CreatePostDto){

    @IsString({
      	message: stringValidationMessage
    })
    @IsOptional()
    title?: string;

    @IsString({
    	message: stringValidationMessage
  	})
    @IsOptional()
  	content?: string;
}
profile
블로그 이전 : https://medium.com/@jaegeunsong97

0개의 댓글