NestJS에서 ArrayBody Validation 하기

윤학·2024년 7월 16일
0

Nestjs

목록 보기
13/13
post-thumbnail

NestJS에서는 기본으로 제공하는 Pipe들 덕분에 쉽게 Request 데이터들을 검증할 수 있고, 간단한 타입 변환도 가능하다.

또한, class-validator를 적용한 ValidationPipe를 활용하면 Request로부터 복잡하게 들어오는 데이터들이라도 Validation 규칙들의 관리를 하기가 수월하다.

그래서 보통은 Request마다 Dto 파일들을 만들고, property마다 class-validator의 Validation 규칙들을 작성하여 관리하지 않을까 생각한다.

나아가서, 모든 Request에 공통적으로 적용할 Validation 규칙을 정해 GlobalPipe로 적용한다면 큰 문제는 없을 것이라 생각했다.

하지만 Request Body의 최상위 요소가 Array로 이루어진 경우 Validation 규칙이 적용되지 않는 것 같아 정리한다.

빠르게 예시를 보자.

일단 Global Validation 규칙은 whitelistforbidNonWhitelisted를 true로 설정하여 Dto에 Validation 데코레이터를 달지 않은 property가 Request로 들어올 경우 오류를 던지도록 해보자.

Dto에 property를 정의했더라도 class-validator 패키지 데코레이터를 달지 않으면 인식하지 않는다.

main.ts

async function bootstrap() {
  const app = await NestFactory.create(MainAppModule);
  
  app.useGlobalPipes(
    new ValidationPipe({
      forbidNonWhitelisted: true,
      whitelist: true,
    })
  )

  await app.listen(3000);
}

Controller에 Router도 하나 만들고

main.controller.ts

@Controller()
export class MainAppController {
  constructor(private readonly mainAppService: MainAppService) {}

  @Post()
  postHello(@Body() arrayDto: ValidationArrayDto[]): string {
    return 'Validation Passed!!!';
  };
}

배열의 각 요소가 따라야 할 Validation 규칙도 작성해 주자

validation-array.dto.ts

export class ValidationArrayDto {
	@IsNotEmpty()
	@IsString()
  	name: string;

	@IsNotEmpty()
	@IsInt()
	@Min(1)
 	age: number;

	@IsNotEmpty()
	@IsString()
	breed: string;
};

일단 설정한 Validation 규칙이 적용될까?

age property는 1 이상인 값을 가져야 하는 규칙을 정의했으므로 0값을 넘겨주면 400 응답이 와야 한다.

beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [MainAppModule],
    }).compile();

    app = module.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
    }))
    await app.init();
  });

  it('/POST', () => {
    return request(app.getHttpServer())
      .post('/')
      .set('Accept', 'application/json')
      .type('application/json')
      .send(
        [
          {
            age: 0, <----------------- 위반
            breed: 'breed',
            name: 'name',
          }
        ]
      )
      .expect(400)
  });

하지만 예상과 다르게 통과해 버린다.

그럼 같은 경우에 GlobalPipe의 다른 Validation 규칙들은 적용되고 있는 것일까?

Dto 파일에 정의하지 않은 suprise property를 추가해 보자.

400 응답을 기다린다.

 beforeEach(async () => {
    const modul: TestingModule = await Test.createTestingModule({
      imports: [MainAppModule],
    }).compile();

    app = module.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
    }))
    await app.init();
  });

  it('/POST', () => {
    return request(app.getHttpServer())
      .post('/')
      .set('Accept', 'application/json')
      .type('application/json')
      .send(
        [
          {
            age: 20,
            breed: 'breed',
            name: 'name',
            suprise: 'suprise'
          }
        ]
      )
      .expect(400)
  });

그대로 통과해버린다.

실패하는 이유는 Nest 문서에 설명하고 있다.

타입스크립트는 Generic과 interface에 대해 메타데이터를 저장하지 않기 때문에 DTO에 사용한다면 Request 데이터를 제대로 Validation 못한다고 한다.(배열도 Generic으로 생각해서...?)

배열을 Validation 하기 위해서는 배열을 감싸는 property를 추가하거나, 별다른 property를 추가하지 않을 거라면 ParseArrayPipe를 해당 Router에 적용하라고 나온다.

그럼 2가지 방법을 모두 살펴보자.

배열을 감싸는 property 추가

기존 배열을 items property로 감싸보자.

export class ValidationArrayDto {
	@IsNotEmpty()
	@IsString()
  	name: string;

	@IsNotEmpty()
	@IsInt()
	@Min(1)
  	age: number;

	@IsNotEmpty()
	@IsString()
  	breed: string;
};
/* items property 추가 */
export class WrapArrayValidationArrayDto {
	@ValidateNested({ each: true })
	@Type(() => ValidationArrayDto)
	items: ValidationArrayDto[]; 
}

Controller도 수정해주고

  @Post()
  postHello(@Body() arrayDto: WrapArrayValidationArrayDto): string {
    return 'Validation Passed!!!';
  };

다시 실패하는지 테스트를 해보면

  it('/POST', () => {
    return request(app.getHttpServer())
      .post('/')
      .set('Accept', 'application/json')
      .type('application/json')
      .send(
          {
            items: [
              {
                age: 0,
                breed: 'breed',
                name: 'name',
                suprise: 'suprise',
              }
            ]
          }
      )
      .expect({
        statusCode: 400,
        error: 'Bad Request',
        message: [
          'items.0.property suprise should not exist',
          'items.0.age must not be less than 1',
        ]
      })
  });

정상적으로 Validation 규칙이 적용되는 것을 확인할 수 있다.

필요한 곳에 ParseArrayPipe 추가

별도의 property를 추가하지 않고, ParseArrayPipe를 필요한 곳에 적용해보자.

일단, Dto를 처음으로 다시 돌려주고

export class ValidationArrayDto {
	@IsNotEmpty()
	@IsString()
  	name: string;

	@IsNotEmpty()
	@IsInt()
	@Min(1)
  	age: number;

	@IsNotEmpty()
	@IsString()
  	breed: string;
};

Router에 ParseArrayPipe를 적용하면서 items 옵션으로 배열의 각 요소에 적용할 Validation 규칙을 작성해 놓은 Dto를 넘겨주자.

  @Post()
  postHello(
    @Body(new ParseArrayPipe({ items: ValidationArrayDto })) arrayDto: ValidationArrayDto[]): string {
    return 'Validation Passed!!!';
  };

그리고 다시 테스트를 해보면

  it('/POST', () => {
    return request(app.getHttpServer())
      .post('/')
      .set('Accept', 'application/json')
      .type('application/json')
      .send(
        [
          {
            age: 0,
            breed: 'breed',
            name: 'name',
            suprise: 'suprise',
          }
        ]
      )
      .expect({
        statusCode: 400,
        error: 'Bad Request',
        message: [
          'property suprise should not exist',
          'age must not be less than 1',
        ]
      })
  });

실패해버렸다.

근데 실패 메시지를 보면 age property의 Validation은 잘 적용된 것을 보니 ParseArrayPipe가 잘 적용된 것 같다.

그럼 property suprise should not exist 실패 메시지는 왜 나오지 않았을까?

해당하는 Validation 규칙은 GlobalPipe에서 막혀야 되는데 처음에 봤듯이 한번 감싸지 않고 배열을 넘겨주는 경우(Request 데이터의 최상위 요소가 배열인 경우)에는 타입 정보를 알지 못해서 통과해 버린다.

이제는 ParseArrayPipe에 items 옵션을 통해 타입 정보를 알려줬으니 GlobalPipe에서 적용했던 추가적인 Validation 규칙들을 다시 작성해 주자.

  @Post()
  postHello(
    @Body(
      new ParseArrayPipe({
        items: ValidationArrayDto, 
        whitelist: true, 
        forbidNonWhitelisted: true 
      })) arrayDto: ValidationArrayDto[]): string {
    return 'Validation Passed!!!';
  };

그럼 정상적으로 Validation이 성공하는 것을 확인할 수 있다.

위 같은 경우에 기존 class-validator에서 제공하는 @ArrayMinSize()와 같은 추가적인 규칙을 적용하려면 CustomPipe를 작성해야 하기에 property로 감싸는 방법이 편해 보인다.

참고

NestJS Validation
Is it possible to validate top-level array by class-validator in NestJS?

0개의 댓글