지금부터는 변경의 완성본 위주로 업로드가 됩니다.
@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에서도 사용이 가능합니다.
export class CreatePostDto {
title: string;
content: string;
}
그리고 controller와 service를 수정합니다.
@Post()
@UseGuards(AccessTokenGuard)
postPosts(
@User('id') userId: number,
@Body() body: CreatePostDto, // 변경
) {
return this.postsService.createPost(userId, body); // 변경
}
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이 아니면 무조건 에러를 던지게 됩니다.
import { IsString } from "class-validator";
export class CreatePostDto {
@IsString() // 변경
title: string;
@IsString() // 변경
content: string;
}
이제 이 validator들을 전역적으로 관리하고 사용하기 위해서 main.ts에 global하게 등록합니다.
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에 관련된 모든 데코레이터들의 목록과 리스트
{
"message": [
"title must be a string"
],
"error": "Bad Request",
"statusCode": 400
}
영어로 나왔던 에러메세지를 직접 커스텀 작성을 해보겠습니다. 그리고 포스트맨으로 테스트를 해보겠습니다.
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
}
클래스를 사용하면 OOP를 활용할 수 있는 가장 큰 장점이 있습니다. 따라서 CreatePostDto 클래스를 좀 더 효율적으로 만들어 보겠습니다. posts.entity.ts로 이동합니다.
CreatePostDto에 title과 content가 있고, posts의 엔티티에도 title과 content가 존재합니다. 중복이 되는 겁니다. 따라서 상속을 이용해서 해결해 보겠습니다.
먼저 validator들을 잘라내서 posts.entity.ts로 이동시킵니다.
export class CreatePostDto {
title: string;
content: string;
}
@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 유틸리티를 이용해서 해결하겠습니다.
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
}
이제는 업데이트를 하는 API putPost를 작업하겠습니다. 따라서 기존의 컨트롤러 코드에서 DTO를 사용하는 코드로 변경하겠습니다. 변경과정은 앞서본 postPost와 비슷하기 때문에 결과만 보여드리도록 하겠습니다. 먼저 UpdatePostDto를 만듭니다.
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 뒤에 ?
가 붙게 되는 것입니다. 이제 컨트롤러를 바꿔주겠습니다.
@Put(':id')
putPost(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdatePostDto, // 변경
) {
return this.postsService.updatePost(id, body); // 변경
}
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
import { PickType } from '@nestjs/mapped-types';
import { UsersModel } from 'src/users/entities/users.entity';
export class RegisterUserDto extends PickType(UsersModel, ['nickname', 'email', 'password']){}
@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에서 변경을 할 수 있습니다.
@Post('register/email')
postRegisterEmail(
@Body() body: RegisterUserDto, // 변경
) {
return this.authService.registerWithEmail(body);
}
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를 작성하는 것이 귀찮다라고 느낄 수 있습니다. 따라서 그 귀찮은 부분을 일반화 해서 해결해 봅시다.
지금까지는 message에 string을 직접 넣기만 했습니다. 사실 message에는 override가 1개더 있습니다. 직접 함수를 작성해서 넣을 수 있다는 것
입니다.
@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 함수를 따로 빼서 작업을 진행하고 테스트를 해보겠습니다.
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]}글자를 입력 해주세요.`;
}
다른 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도 만들도록 하겠습니다.
export const stringValidationMessage = (args: ValidationArguments) => {
return `${args.property}에 String을 입력해주세요!`;
}
export const emailValidationMessage = (args: ValidationArguments) => {
return `${args.property}에 정확한 이메일을 입력해주세요!`;
}
그리고 users.entity.ts, posts.entity.ts, UpdatePostDto에 전부 적용을 합니다. CreatePostDto와 RegisterUserDto는 상속을 받았기 때문에 할 필요가 없습니다.
@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[];
}
@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;
}
export class UpdatePostDto extends PartialType(CreatePostDto){
@IsString({
message: stringValidationMessage
})
@IsOptional()
title?: string;
@IsString({
message: stringValidationMessage
})
@IsOptional()
content?: string;
}