ValidationPipe
의 Error Response 생성의 이해class-validator
를 이용하여 RequestBody
에 대한 유효성을 검증하였다. ErrorResponse
를 통일하는 작업을 진행하려다 보니 class-validator
에서는 어떠한 방식으로 ErrorResponse
를 만들어서 보여줄까? 궁금증이 발생하여 라이브러리를 뜯어보기로 하였다.
NestJs에서는 요청이 들어오는 Request에 대한 Validation을 주로 class-validator
를 이용하여 유효한지 검증을 하게 된다.(NestJs Validation)
class CreateExampleRequest {
@IsString()
readonly title: string;
@IsString()
readonly content: string;
//...
}
다음 main.ts
에서 아래와 같은 코드를 추가하면 들어오는 요청에 대해 validation 데코레이터가 붙어있는 프로퍼티에 대한 유효성 검증을 할 수 있다.
// main.ts
async function bootstrap() {
//...
app.useGlobalPipes(new ValidationPipe({ transform: true })); // 1
//...
}
// 유효하지 않을 때 Error Response
{
"statusCode": 400,
"message": [
"title should not be empty",
"content should not be empty",
],
"error": "Bad Request"
}
{ transform: true }
옵션을 추가함으로 유효성 검증이 끝난 object
를 controller
에서 정의한 class
로 받을 수 있도록 한다.그렇다면 어떻게 위와 같은 Response를 Client에서 어떻게 만들어서 보내는 걸까?
ValidationPipe
는 @nestjs/common
모듈에 속해있다. 그렇기 때문에 Nestjs를 설치한 프로젝트라면 당연히 들어있을 것이다. NestJs는 기본적으로 typescript
가 적용된 프로젝트이기 때문에 진짜 구현체를 확인하려면 .js
파일을 확인하면 된다.
node_modules
ㄴ common
ㄴ pipes
ㄴ validation.pipe.d.ts
ㄴ validation.pipe.js # 구현 코드가 들어있는 파일
// ...
이제 validation.pipe.js
를 통해 어떻게 ErrorResponse가 어떻게 만들어지는지 확인해보자.
필자는 Response가 만들어지는 과정이 궁금하기 때문에 ValidationPipe
에서 createExceptionFactory
를 조금 집중해서 보려고 한다.
main.ts
에서 ValidationPipe
를 생성할 때 exceptionFactory
라는 옵션을 줄 수 있다. 해당 Option의 파라미터로는 (errors: ValidationError[]) => any;
형태의 콜백을 받게 되며 파라미터로 들어오는 errors
에는 유효성을 검사에 실패한 프로퍼티의 정보와 Error 정보를 가지고 있다. 만약 요청 데이터가 전부 유효하다면 createExceptionFactory
는 호출되지 않는다.
여기서 ValidationError
의 타입을 추적하기 위해 validation-error.interface.d.ts
파일을 확인하면 다음과 같다.
export interface ValidationError {
target?: Record<string, any>;
property: string;
value?: any;
constraints?: {
[type: string]: string;
};
children?: ValidationError[];
contexts?: {
[type: string]: any;
};
}
추가적으로 ValidationPipe
의 ErrorResponse를 사용자가 커스텀이 가능하다. 만약 해당 옵션을 부여하지 않으면 ValidationPipe
는 빌트인 되어 있는 createExceptionFactory
를 사용하게 된다.
let ValidationPipe = class ValidationPipe {
constructor(options) {
// ...
this.exceptionFactory = options.exceptionFactory || this.createExceptionFactory();
// ...
}
그럼 이제 빌트인 되어 있는 createExceptionFactory
를 확인하기 위해 validation.pipe.js
파일을 살펴보자.
//validation.pipe.js
createExceptionFactory() {
return (validationErrors = []) => {
if (this.isDetailedOutputDisabled) { // 1
return new http_error_by_code_util_1.HttpErrorByCode[this.errorHttpStatusCode]();
}
const errors = this.flattenValidationErrors(validationErrors); // 2
return new http_error_by_code_util_1.HttpErrorByCode[this.errorHttpStatusCode](errors); // 3
};
}
if (this.isDetailedOutputDisabled)
isDetailedOutputDisabled
옵션 또한 마찬가지로 ValidationPipe
를 생성할 때 부여할 수 있는 옵션이다. disableErrorMessages
옵션을 true
로 주게되면 Client에게 유효하지 않은 요청에 대한 응답을 보낼 때 Error 메세지는 응답하지 않게 된다. 즉 아래와 같이 statusCode
와 error
만 응답되게 된다.
{
"statusCode": 400,
"error": "Bad Request"
}
HttpErrorByCode
는 @nest/common
에 util
에 들어있으며 Http Status 코드가지고 NestJs
에 빌트인 되어있는 HttpException
과 매핑 해주는 역할을 한다.
참고로 ValidatonPipe
의 기본 Http Status 코드는 400이며 ValidationPipe
를 생성할 때 이 또한 커스텀 할 수 있다.
const errors = this.flattenValidationErrors(validationErrors)
;ErrorResponse가 어떻게 만들어지는지 알고자 한다면 중점적으로 봐야하는 부분이다.
flattenValidationErrors
는 아래와 같이 생겼다.
flattenValidationErrors(validationErrors) {
return (0, iterare_1.iterate)(validationErrors) // 1
.map(error => this.mapChildrenToValidationErrors(error)) // 2
.flatten() // 3
.filter(item => !!item.constraints) // 4
.map(item => Object.values(item.constraints)) // 5
.flatten() // 6
.toArray(); // 7
}
인자로 들어온 validationErrors
를 순회하는 API를 사용하기 위해 iterate
라이브러리로 한번 래핑한다.
map
을 이용하여 validationErrors
의 요소들을 순회하며 mapChildrenToValidationErrors
를 실행한다. (자세한 건 아래서 확인해보자.)
mapChildrenToValidationErrors
의 결과를 가지고 flatten
을 이용하여 배열을 평탄화 한다. 그 이유는 유효성 검증에 대상이 되는 프로퍼티에 여러개의 유효성 검증 데코레이터가 적용되어 있으며 Nested Object
혹은 Array
에 대한 Validation도 진행해야 하기 때문이다.
validationErrors
에서 실제로 Client에게 보여지는 message는 constraints
이기 때문에 constraints
가 있는 error만 필터링 한다.
error에서 constraints
만 map
을 이용하여 뽑아온다.
Client에게 응답되는 ErrorResponse에는 1차원 배열 안에 유효성 검증에 실패한 모든 Error 메세지가 포함되기 때문에 평탄화를 진행한다.
배열로 변경 후 리턴하게 된다.
return new http_error_by_code_util_1.HttpErrorByCode[this.errorHttpStatusCode](errors)
;2번에서 리턴된 Error들의 constraints
를 받아 NestJs
의 빌트인 된 HttpException
의 생성자와 같이 호출되면서 message
라는 프로퍼티에 들어가 응답이 되게 되는 것이다.
flattenValidationErrors
에서 사용된 method
Error에 대한 평탄화를 위해 validationErrors
를 순회하며 내부적으로 호출되는 함수이며 최종적인 ErrorMessage를 만드는 과정들이 들어있다. flattenValidationErrors
를 간단히 정리하면 유효성 검증에 실패한 모든 프로퍼티에 대한 Error Message를 정리하고 역할을 한다. 여기서 말한 모든 프로퍼티에는 데코레이터가 여러 개 적용된 프로퍼티, Nested Object
, Array
등등에 해당된다.
mapChildrenToValidationErrors
는 validationErrors
이 순회하면서 가장 먼저 호출되는 api이다. 생김세는 아래와 같다.
mapChildrenToValidationErrors(error, parentPath) {
if (!(error.children && error.children.length)) { // 1
return [error];
}
const validationErrors = []; // 2
parentPath = parentPath // 3
? `${parentPath}.${error.property}`
: error.property;
for (const item of error.children) { // 4
if (item.children && item.children.length) {
validationErrors.push(...this.mapChildrenToValidationErrors(item, parentPath));
}
validationErrors.push(this.prependConstraintsWithParentProp(parentPath, item));
}
return validationErrors;
}
인자로 들어온 error가 children을 가지고 있지 않는다면 즉 중첩되어 있는 Error를 가지고 있지 않다면 Return한다. (Error 가 없는 경우)
새로운 Error 배열을 생성한다.
부모의 경로를 정의한다. 인자로 들어온 parentPath
의 존재 유무로 결정된다. parentPath
의 쓰임세는 prependConstraintsWithParentProp
에서 확인할 수 있다.
인자로 들어온 error를 순회하면서 계속해서 내부 Error를 탐색해 나간다. 여기서 자식이 있는 경우와 자식이 없는 경우로 나뉘어지는데 유효성 검증 대상의 프로퍼티가 데코레이터가 여러 개 적용된 프로퍼티, Nested Object
, Array
등등 경우가 다양하게 있기 때문에 mapChildrenToValidationErrors
를 재귀하면서 최하위까지 타고 들어간다.
prependConstraintsWithParentProp
는 간단하게 이야기 하면 Error로 응답되는 Message의 Key값에 부모가 있다면 부모의 이름을 붙혀주는 역할 및 key 값에 걸맞는 constraints를 매핑해주는 역할이다. 이 후 인자로 들어온 error에 프로퍼티에 constraints
를 추가하거나 덮어 쓰여지게 된다.
prependConstraintsWithParentProp(parentPath, error) {
const constraints = {};
for (const key in error.constraints) {
constraints[key] = `${parentPath}.${error.constraints[key]}`;
}
return Object.assign(Object.assign({}, error), { constraints });
}
즉 Array
와 Nested Object
의 경우가 이에 해당하며 해당 로직을 거쳐야 아래와 같은 ErrorResponse의 형태를 띄게 된다.
// EX
"message": [
// ...
"person.name must be a string",
]
라이브러리의 코드를 하나씩 살펴보면서 NestJs
가 class-validator
를 통해 들어온 Error를 어떻게 가공하고 사용하는지에 대해 알아 볼 수 있었다. 이 과정을 통해 라이브러리의 코드를 열어본다는 자신감을 얻을 수 있었으며 추 후 NestJs
에서 Error 공통화를 할 때 조금 더 도움이 될 수 있을 것 같다는 생각을 하게 되었다. (정작 )class-validator
에서 Error를 어떻게 만들어 CallBack으로 던지는지에 대한 내용은 없다...