pipe를 사용하는 목적은 딱 두가지인데, 하나는 transformation으로 값을 바꾸는데 있고, 다른 하나는 validation으로 값을 검사하는데 있다. pipe는 HTTP 핸들러로 넘어오는 클라이언트의 요청 데이터를 검사하거나 이후 로직에서 다루기 편리하게 데이터의 형식이나 값을 바꿔준다.
단순한 검사나 변환에는 nest가 제공하는 내장 파이프를 적용하고, 좀 더 복잡한 경우에는 커스텀 파이프를 생성하여 적용할 수 있다. 특히 검사의 경우 validator 데코레이터로 dto를 만들어 적용하는 편이다.
pipe는 두 가지 용도로 사용된다. transformation이거나 validation이거나.
HTTP 요청과 함께 전달된 input data를 개발자가 원하는 양식으로 변환하기 위해 사용된다. string에서 integer로 타입을 바꾼다던지, 값을 조작해서 특정 양식이 적용된 값을 만들어준다.
nest가 제공하는 빌트인 pipe가 몇 개 있다. input data의 타입을 지정한 타입으로 바꾸기 위해서 사용된다. 대표적으로 쓰이는 pipe로는,
1. ParseIntPipe
2. ParseBoolPipe
3. ParseArrayPipe
4. ParseEnumPipe
등이 있다. 정말 단순하게 타입만 바꾼다면 커스텀 pipe까지 만들 필요가 없이 빌트인 pipe를 사용하는게 더 효율적이다.
pipe를 적용하는 것은 간단하다. 라우트 핸들러의 argument에 데이터를 받는 @Param
, @Body
, @Query
등의 데코레이터를 이용해 데이터의 어떤 값을 어떤 파이프에 적용할 것인지 지정해주면 된다.
@Get('id')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.catService.findOne(id)
}
만약 위의 id에 숫자 string이 아닌 영어 string이 온다면 ParseIntPipe는 이를 처리할 수가 없고 내장된 exception filter에 의해 다음과 같은 에러값을 반환한다.
{
"statusCode": 400,
"message" : "Validation failed (numberic string is expected)",
"error" : "Bad Request"
}
발생하는 에러를 지정해주고 싶다면 다음과 같이 작성한다.
@Get('id')
async findOne(
@Param('id', new ParseIntPipe({errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE}))
id: number
) {
return this.catService.findOne(id)
}
직접 값을 받아서 이를 원하는대로 변형할 수 있다.
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class ValidationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata){
return value;
}
}
커스텀 pipe를 만들 때 주의할 점은 @Injectable
을 명시하고 PipeTransform
을 상속받는 클래스를 만들어 transform
이라는 메소드를 명시해줘야한다. 이때 파라미터인 value는 우리가 바꿀 값이 된다. 위의 예시에서는 Param의 id값이 value로 들어온다. metadata는 pipe가 어떤 경유로 등록되었는지에 대한 정보를 담고있는데 대략 아래와 같은 모습의 인터페이스이다.
export interface ArgumentMetadata {
type: 'body' | 'query' | 'param' | 'custum',
metatype?: Type<unknown>,
data?: string
}
type은 input data가 body로 들어왔는지 query로 들어왔는지를 의미하고, metatype은 라우트 핸들러에 등록된 parameter의 타입이라고 한다(Binding pipe를 예시로 한다면 parameter인 id를 number로 정의했기에 Number가 될 것이다). data의 경우 데코레이터에 넘겨진 string값으로 @Body('id')
의 경우 'id'가 data가 된다.
HTTP 요청과 함께 전달된 input data의 타입이 서버가 원하는 유효한 타입인지 확인을 해주기 위해 사용된다. 만약 유효한 값이 아니면 예외가 발생되고 exception filter가 이를 처리하면서 라우트 핸들러 함수는 실행되지 않는다.
validation을 하는 방법 중 대표적인 방법으로 class-validator
라이브러리를 사용하는 방법이 있다. 라이브러리에서 제공해주는 데코레이터를 기반을 validation을 하는 것인데 dto 개념과 결합해서 많이 사용한다.
타입 검사를 위해서 아래와 같이 Dto 클래스를 만들고 들어올 인자에 대해서 개별적으로 타입 검사 데코레이터를 붙여주면 타입 검사까지는 해준다.
import { IsString, IsInt } from 'class-validator';
export class CreateCatDto {
@IsString()
name: string;
@IsInt()
age: number;
@IsString()
breed: string;
}
사용은 아래와 같이 하면 된다.
@Post()
async create(@Body() createCatDto: CreateCatDto){
this.catsService.create(createCatDto);
}
이제 좀 더 세밀한 검사를 위해서 커스텀 validation pipe를 만들어 schema based validation과 조합해서 사용해보자. 기존에는 @param('id')
같이 네이티브 자바스크립트 타입만을 검사했는데 이번엔 schema로 미리 지정해둔 class 형태인지 검사를 해보는 것이다. 위의 schema를 그대로 사용한다고 가정하겠다. 아래와 같이 새롭게 커스텀 validation pipe를 만들고,
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from @nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
async transform(value: any, { metatype }: ArgumentMetadata) {
if (!metatype || !this.toValidate(metatype)) {
return value;
}
const object = plainToClass(metatype, value);
const errors = await validate(object);
if (errors.length > 0) {
throw new BadRequestException('Validation failed');
}
return value;
}
private toValidate(metatype): boolean {
const types = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
}
아래처럼 바인딩을 해주면 create 라우트 핸들러로 들어오는 요청은 모두 CreateCatDto 타입과 같은지 검사에 들어갈 것이다.
@Post()
async create(@Body(ValidationPipe) createCatDto: CreateCatDto) { ... }
만약 요청이 {"name": "catt", "age":1, "breed": "scottish"}
으로 들어온다면 value는 {"name": "catt", "age":1, "breed": "scottish"}
이고, metatype은 CreateCatDto가 된다. toValidate 메소드를 통해서 metatype이 네이티브 자바스크립트 타입인지 검사를 하고 plainToClass를 통해서 타입이 명시되지 않은 value에 metatype을 바인딩한다. 이는 validate로 타입 검사를 하기 위함이다.
함수형 프로그래밍을 해봤다면 pipe가 무슨 개념인지 알 것이다. 인풋이 있다면 적절히 변형해서 아웃풋을 만들고 다시 인풋으로 받아 적절히 변형하는 과정을 반복하는 것인데 이는 pipe의 연결과도 같다. 이처럼 라우트 핸들러를 통해서 받은 input data에 대해 여러 개의 pipe를 순차적으로 적용할 수 있기 때문에 pipe라고 부르는 것이다.
@Get('weight')
async findOne(@Param('weight', ParseIntPipe, OversizePipe) id: number) { ... }
위의 예시에서는 weight에 대해 int형으로 변환을 시켜준 다음 오버사이즈 검사를 하고 있다.