【NestJS】カスタムバリデーションパイプを用いた入力チェックについて考える

# 前提
- [公式：NestJS Validation](https://docs.nestjs.com/techniques/validation)の入力チェックをするValidationPipeについての記事であること
- 簡単な備忘段階であり今後更新していく前提で記載していること

# 参考
- [NestJS 公式ドキュメントver7日本語訳：Chapter08 overview-pipes](https://zenn.dev/kisihara_c/books/nest-officialdoc-jp/viewer/overview-pipes)
- [class-validator](https://github.com/typestack/class-validator)

# はじめに
省略

# class-validatorのエラーレスポンスについて
### 準備
ValidationPipeクラスを定義し、まずはclass-validatorのvalidateメソッドが吐くエラーレスポンスをログに出力
[src/validation.pipe.ts]
```TS
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) {
      console.log(errors)//エラーハンドリングを考えるためログ出力してみる
    }
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}
```
main.tsにて、入力チェックをグローバルで実行するように設定
[src/main.ts]
```TS
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from './validation.pipe';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());//ValidationPipeをグローバル設定
  await app.listen(3000);
}
bootstrap();
```
### シチュエーション
サインインをする際、ユーザに"Emailアドレス"と"パスワード"を入力に求める場合を想定する
[src/signIn/dto/signIn.dto.ts]
```TS
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';

export class SignInDTO {
  @ApiProperty({
    description: 'Emailアドレス',
    example: 'xxx@yyy.com',
  })
  @IsNotEmpty()//空でないこと
  @IsEmail()//Emailのフォーマットを満たしていること
  email: string;

  @ApiProperty({
    description: 'パスワード',
    example: 'hogehoge',
  })
  @IsNotEmpty()//空でないこと
  @IsString()//文字列であること
  password: string;
}
```
コントローラにて、@Bodyに上記のDTOを指定
[src/signIn/signIn.controller.ts]
```TS
import { SignInDTO } from './dto/signIn.dto';

@Controller('signIn')
export class SignInController {
    @Post('')
    async signIn(@Body() signInDTO: SignInDTO) {
        ~ 略 ~
    }
```
### 検証
アプリケーションを起動後、"email"と"password"にしてサインインAPIを叩く。
```sh
$ curl -X 'POST' \
  'http://localhost:3000/signIn' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "email": "",
  "password": ""
}'
```
すると、以下のようなログがターミナルに出力された。([class-validator](https://github.com/typestack/class-validator)を見ればわかることだが･･･)

```sh
[
  ValidationError {
    target: SignInDTO { email: '', password: '' },
    value: '',
    property: 'email',
    children: [],
    constraints: {
      isEmail: 'email must be an email',
      isNotEmpty: 'email should not be empty'
    }
  },
  ValidationError {
    target: SignInDTO { email: '', password: '' },
    value: '',
    property: 'password',
    children: [],
    constraints: { isNotEmpty: 'password should not be empty' }
  }
]
```
# 最初のエラー項目についてのみエラーレスポンスを返す場合
[src/validation.pipe.ts]
```TS
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) {
      const firstErrorProperty: string = errors[0].property;//error配列の最初の要素のpropertyを取得
      let errorCode: string;
      let errorMessage: string;

      switch (firstErrorProperty) {//firstErrorPropertyの値によりハンドリング
        case 'email':
          errorCode = 'E00001'
          errorMessage = 'メールアドレスの形式が不正です。'
          break;
        case 'password':
          errorCode = 'E00002'
          errorMessage = 'パスワードの形式が不正です。'
          break;
        default:
          errorCode = 'E99999'
          errorMessage = '入力項目が不正です。'
          break;
      }
      throw new BadRequestException({//errorCodeとerrorMessage含んだオブジェクトを返却
        errorCode: errorCode,
        errorMessage: errorMessage
      });
    }
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}
```
### 検証
アプリケーションを起動後、最初と同じリクエストボディでサインインAPIを叩いてみる。
```sh
$ curl -X 'POST' \
  'http://localhost:3000/signIn' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "email": "",
  "password": ""
}'
```
すると、想定通り以下のようにEmailに対するエラーレスポンスが帰ってきた

```sh
{
  "errorCode": "E00001",
  "errorMessage": "メールアドレスの形式が不正です。"
}
```
今度は"email"に対して入力形式を満たす場合を試してみる
```sh
$ curl -X 'POST' \
  'http://localhost:3000/signIn' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "email": "test@test.com",
  "password": ""
}'
```
すると、想定通り以下のようにパスワードに対するエラーレスポンスが帰ってきた

```sh
{
  "errorCode": "E00002",
  "errorMessage": "パスワードの形式が不正です。"
}
```
### まとめ
プロパティの｢どこが悪いのか｣詳細に返す場合はconstraintsに応じて条件を追加する必要があるが、｢このプロパティが悪い｣といった抽象的なエラーで良い場合はこれで良いかもしれない。
また、今回は出てこなかったが、@MinLengthなどはdtoでエラーレスポンスまで定義できるようで、まだまだ調査する必要がある。
[[class-validator：custom-validation-decorators](https://github.com/typestack/class-validator#custom-validation-decorators)より]
```TS
  @MinLength(32, {
    message: 'EIC code must be at least 32 characters',
    context: {
      errorCode: 1003,
      developerNote: 'The validated string must contain 32 or more characters.',
    },
  })
  eicCode: string;
```
# 追記 2022/12/20：よりスマートなエラーハンドリングを目指して
### class-validatorのValidationOptionsについて
上記まとめに記載した@MinLengthには第二引数に[ValidationOptions](https://github.com/typestack/class-validator/blob/5c56c122670a247837eda23167f8fb9e6049af7e/src/decorator/ValidationOptions.ts#L6)というオプション引数が指定されているが、実は全てのデコレーションの引数に対して定義することができる。
[[class-validator：MinLength](https://github.com/typestack/class-validator/blob/5c56c122670a247837eda23167f8fb9e6049af7e/src/decorator/string/MinLength.ts)]
```TS
export function MinLength(min: number, validationOptions?: ValidationOptions): PropertyDecorator {
```
今回は、このValidationOptionsの中のcontextがany型であることを利用して、errorCodeとerrorMessageを付け加えてみる。
[[class-validator：ValidationOptions](https://github.com/typestack/class-validator/blob/5c56c122670a247837eda23167f8fb9e6049af7e/src/decorator/ValidationOptions.ts#L6)]
```TS
/**
 * Options used to pass to validation decorators.
 */
export interface ValidationOptions {
  /**
   * Specifies if validated value is an array and each of its items must be validated.
   */
  each?: boolean;

  /**
   * Error message to be used on validation fail.
   * Message can be either string or a function that returns a string.
   */
  message?: string | ((validationArguments: ValidationArguments) => string);

  /**
   * Validation groups used for this validation.
   */
  groups?: string[];

  /**
   * Indicates if validation must be performed always, no matter of validation groups used.
   */
  always?: boolean;

  /*
   * A transient set of data passed through to the validation result for response mapping
   */
  context?: any;//ここにエラーレスポンスを定義していく
}
```
### 準備
DTOを以下のように修正する。検証してみてわかったことだが、変数に近いデコレーションから順に評価されるので注意。
また、デコレーションによってValidationOptionsが第一引数なのか第二引数なのか異なるため、[class-validator](https://github.com/typestack/class-validator)を参照すること。
[src/signIn/dto/signIn.dto.ts]
```TS
export class SignInDTO {
  @ApiProperty({
    description: 'Emailアドレス',
    example: 'xxx@yyy.com',
  })
  @IsEmail(undefined,{//第二引数がValidationOptionsなので第一引数にundefinedを代入
    context: {
      errorCode: 'E00002',
      errorMessage: 'メールアドレスの形式が不正です。',
    },
  })//emailのチェックの中で2番目に評価される
  @IsNotEmpty({
    context: {
      errorCode: 'E00001',
      errorMessage: 'メールアドレスを入力してください。',
    },
  })//emailのチェックの中で1番目に評価される
  email: string;

  @ApiProperty({
    description: 'パスワード',
    example: 'gihgiugiu',
  })
  @IsString({
    context: {
      errorCode: 'E00012',
      errorMessage: 'パスワードは文字列で入力してください。',
    },
  })//passwordのチェックの中で2番目に評価される
  @IsNotEmpty({
    context: {
      errorCode: 'E00011',
      errorMessage: 'パスワードを入力してください。',
    },
  })//passwordのチェックの中で1番目に評価される
  password: string;
}
```
validation.pipe.tsのtransformメソッドを以下のように修正する。
[src/validation.pipe.ts]
```TS
  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) {
      console.log(errors)//エラー全体をログ出力してみる
      Object.keys(errors[0].contexts).map(key => {//errors最初の要素（メールアドレス）のキーでループ
        console.log(errors[0].contexts[key]);//キーの値であるerrorCodeとerrorMessageをログ出力してみる
        //throw new BadRequestException(errors[0].contexts[key]);
      })
    }
    return value;
  }
```
### 検証
```sh
$ curl -X 'POST' \
  'http://localhost:3000/signIn' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
  "email": "",
  "password": null
}'
```
ターミナルの出力結果
```sh
[
  ValidationError {
    target: SignInDTO { email: '', password: null },
    value: '',
    property: 'email',
    children: [],
    constraints: {
      isNotEmpty: 'email should not be empty',
      isEmail: 'email must be an email'
    },
    contexts: { isNotEmpty: [Object], isEmail: [Object] }//contextsが追加された
  },
  ValidationError {
    target: SignInDTO { email: '', password: null },
    value: null,
    property: 'password',
    children: [],
    constraints: {
      isNotEmpty: 'password should not be empty',
      isString: 'password must be a string'
    },
    contexts: { isNotEmpty: [Object], isString: [Object] }//contextsが追加された
  }
]
{ errorCode: 'E00001', errorMessage: 'メールアドレスを入力してください。' }//emailの@IsNotEmptyに設定したcontexts
{ errorCode: 'E00002', errorMessage: 'メールアドレスの形式が不正です。' }//emailの@IsEmailに設定したcontexts
```
### 最終形
[src/validation.pipe.ts]
```TS
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';

type ErrorResponse = {
  errorCode: string
  errorMessage: string
}

@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) {
      Object.keys(errors[0].contexts).map(key => {
        const firstErrorResponse: ErrorResponse = errors[0].contexts[key];
        console.log(firstErrorResponse);
        throw new BadRequestException(firstErrorResponse);
      })
    }
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}
```
エラーレスポンス
```sh
{
  "errorCode": "E00001",
  "errorMessage": "メールアドレスの形式が不正です。"
}
```
# おわりに
手探りで考えている最中の備忘です。今後編集を加えていく予定ですのでストックやコメントいただけたら幸いです。