Chapter 25

techniques-validation

kisihara.c
kisihara.c
2021.04.01に更新

バリデーション

Webアプリケーションに送られてくるあらゆるデータを検証する事はベストプラクティスと言えよう。Nestでは、受信したリクエストを自動的に検証する為、あっという間に使えるいくつかのパイプが用意されている。

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe

ValidationPipeは、強力なclass-validatorパッケージとその宣言型検証デコレータを利用している。受信する全てのクライアントのペイロードに対して検証ルールを実施する為の便利なアプローチを提供する。その中でも特定のルールは、各モジュールのローカルクラス・ローカルDTOの中で宣言される際、アノテーションを使う事で発効される。

Overview

パイプの章では、シンプルなパイプを作った。コントローラやメソッド、あるいはグローバルアプリにバインドして、プロセスがどのように機能するかを説明した。本章のトピックを理解する為に、ぜひ事前に復習してほしい。ここからはValidationPipeの実際の使用例に焦点を当て、高度なカスタマイズ機能の使用方法を紹介する。

組み込みのValidationPipeを使う

HINT
ValidationPipe@nestjs/commonパッケージからエクスポートされる。

このパイプはclass-validatorclass-transformerライブラリを使用している為、多くのオプションが用意されている。設定オブジェクトをパイプに渡す事で設定できる。以下は組み込みのオプションだ。

export interface ValidationPipeOptions extends ValidatorOptions {
  transform?: boolean;
  disableErrorMessages?: boolean;
  exceptionFactory?: (errors: ValidationError[]) => any;
}

これらに加えて、全てのclass-validatorのオプション(Validatorインターフェイスから継承されたもの)が使用できる。

Option 説明
skipMissingProperties boolean true時、バリデータは検証対象のオブジェクトに中にないすべてのプロパティの検証をスキップする。
whitelist boolean true時バリデータは検証済みの(返ってきた)オブジェクトから検証デコレータを使用していないプロパティを取り除く。
forbidNonWhitelisted boolean true時、バリデータは、ホワイトリストに載ってないプロパティを、取り除かずに例外とする。
forbidUnknownValues boolean true時、未知のオブジェクトを検証しようとした際速やかに失敗する。
disableErrorMessages boolean true時、検証エラーはクライアントに返されない。
errorHttpStatusCode number この設定により、エラーが発生した場合に使用する例外の型を指定できる。デフォルトではBadRequestExceptionを投げる。
exceptionFactory Function Functionバリデーションエラーの配列を受け取り、投げられるであろう例外オブジェクトを返す。
groups string[] Groups to be used during validation of the object.(ニュアンスを訳せず…)
dismissDefaultMessages boolean true時、検証はデフォルトメッセージを用いない。エラーメッセージが明示的に設定されていない場合、常に未定義となる。
validationError.target boolean ValidationErrorの中で対象が公開されるかどうかを示す。
validationError.value boolean 検証された値がValidationErrorの中で表出されるべきかを示す。

NOTICE
class-validatorパッケージの詳細については、そのリポジトリを参照してください。

オートバリデーション

ValidationPipeをアプリケーションレベルでバインドする事で、全てのエンドポイントが不正なデータの受信から保護されるようにしてみる。

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

そしてパイプをテストする為、ベーシックなエンドポイントを作ってみよう。

@Post()
create(@Body() createUserDto: CreateUserDto) {
  return 'This action adds a new user';
}

HINT
TypeScriptではジェネリックやインターフェイスに関するメタデータが保存されていないため、これらをDTOで使用するとValidationPipeが受信データを正しく検証できない場合がある。よって、DTOにコンクリートクラスを使用する事を検討してほしい。

それではCreateUserDtoにいくつかの検証ルールを追加してみよう。class-validatorパッケージで提供されているデコレータを使用する。この方法をとると、CreateuserDtoを使用する全てのrouteは、自動的にこれらの検証ルールを実施する。

import { IsEmail, IsNotEmpty } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsNotEmpty()
  password: string;
}

リクエストのbodyに無効なemailプロパティが含まれるリクエストがエンドポイントに届いた場合、アプリケーションは自動的に400 Bad Requestコードと次のようなレスポンスbodyを返す。

{
  "statusCode": 400,
  "error": "Bad Request",
  "message": ["email must be an email"]
}

ValidationPipeは、リクエストbodyの検証に加えて、他のリクエストオブジェクトのプロパティにも使用できる。例えば、エンドポイントのパスで:idを受けたいとする。このリクエストパラメータで数字しか受け付けないようにするには、以下のコードを使う。

@Get(':id')
findOne(@Param() params: FindOneParams) {
  return 'This action returns a user';
}

FindOneParamsDTOと同様に、class-validatorを使って検証ルールを定義した単なるクラスだ。以下のようになる。

import { IsNumberString } from 'class-validator';

export class FindOneParams {
  @IsNumberString()
  id: number;
}

詳細なエラーを無効にする

エラーメッセージはリクエストのどこが間違っているかを説明するのに役立つ。しかし運用環境によっては、詳細なエラーを無効にしたい場合もある。この場合、ValidationPipeにオプション・オブジェクトを渡す。

app.useGlobalPipes(
  new ValidationPipe({
    disableErrorMessages: true,
  }),
);

これで詳細なエラーメッセージがレスポンスbodyに表示されなくなる。

プロパティの除去

ValidationPipeは、メソッドハンドラが受け取るべきでないプロパティをフィルタリングする事もできる。この場合、受け入れ可能なプロパティをホワイトリストに登録すると、ホワイトリストに含まれないプロパティはオブジェクトから自動的に除去される。例えば、ハンドラがemailpasswordプロパティを期待しているにも関わらずリクエストにageプロパティが含まれていた場合、このプロパティは結果のDTOから自動的に削除される。このような動作を有効にするには、whitelisttrueに設定する。

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
  }),
);

trueに設定すると、ホワイトリストに登録されていないプロパティ(バリデーションクラスにデコレータがないもの)が自動的に削除される。

また、ホワイトリストに登録されていないプロパティが存在する場合、リクエストの処理を停止して、ユーザにエラーレスポンスを返すこともできる。オプションのforbidNonWhitelstedプロパティをtruewhitelisttrueに設定する。

ペイロードオブジェクトの変換

ネットワーク経由で送られてくるペイロードは、プレーンなJavaScriptオブジェクトだ。ValidationPipeは、ペイロードをDTOクラスに応じて片付けされたオブジェクトに自動的に変換する事もできる。自動変換を有効にするには、transformtrueに設定する。これは、メソッドレベルで行える。

@Post()
@UsePipes(new ValidationPipe({ transform: true }))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

この動作をグローバルに有効化するなら、グローバルパイプに設定しよう。

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
  }),
);

自動変換オプションを有効にすると、ValidationPipeはプリミティブな型の変換も行う。次の例では、findeOne()メソッドは、抽出されたidパスパラメータを表す1つの引数を取る。

@Get(':id')
findOne(@Param('id') id: number) {
  console.log(typeof id === 'number'); // true
  return 'This action returns a user';
}

デフォルトでは、パスパラメータやクエリパラメータは全て文字列としてネットワーク上に送信される。上記の例ではidの型を(メソッドのシグネチャ内で)numberとして指定している。その為、ValidationPipeは文字列の識別子を自動的に数字に変換しようとする。

明示的な変換

ここまでValidationPipeが暗黙のうちにクエリやパスのパラメータを想定される型に基づいて変換する方法を示した。しかしながらこの機能はこちらで自動変換を有効化する必要がある。

そうではなく(自動変換は無効化して)ParseIntPipeParseBoolPipeを用いて明示的に値をキャストできる。
※前述のように、全てのパスパラメータとクエリパラメータはデフォルトで文字列としてネットワークの向こう側に送信される為、ParseStringPipeが必要とされる事はない

@Get(':id')
findOne(
  @Param('id', ParseIntPipe) id: number,
  @Query('sort', ParseBoolPipe) sort: boolean,
) {
  console.log(typeof id === 'number'); // true
  console.log(typeof sort === 'boolean'); // true
  return 'This action returns a user';
}

HINT
ParseIntPipeParseBoolPipe@nestjs/commonパッケージからエクスポートされている。

型マッピング

CRUD(Create/Read/Update/Delete)のような機能を構築していく際に、ベースであるエンティティタイプのバリエーションを揃えると良い感じになる。Nestはこの作業をより便利にする為に、型変換を行ういくつかのユーティリティ関数を提供している。

WARNING
アプリケーションが@nestjs/swaggerパッケージを使用している場合、型マッピングについての詳細はNestJS公式 OPENAPI-Mapped Typesを参照してほしい。同様に、@nestjs/graphqlパッケージの場合はGRAPHQL-mapped typesをお願いする。どちらのパッケージも型に大きく依存しているので、使用するには別のインポートが必要となる。その為@nestjs/mapped-typesを(アプリの種類に応じて@nestjs/swagger@nestjs/graphqlと使い分けずに)使用した場合、ドキュメントになっていない多様な副作用が発生する。

型の検証(DTO)を構築する際には、同じtypeでcreateupdateのバリエーションを構築するやり方をやりたくなる。例えば、createバリアントでは全てのフィールドを必須としたくて、updateバリアントでは全てのフィールドをオプションとしたいかもしれない。

Nestはこの作業を簡単にして定型文を最小限にする為に、PartialType()ユーティリティ関数を提供している。

PartialType()関数は、入力した型の全てのプロパティを省略可能に設定した型(クラス)を返す。例えば、以下のようなcreate型があるとする。

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

デフォルトではこれらのフィールドは全て必須だ。同じフィールドを持ち、全てがオプションである型を作成するには、引数としてクラス参照(CreateCatDto)を渡してPartialType()を使用する。

export class UpdateCatDto extends PartialType(CreateCatDto) {}

HINT
PartialType()関数は@nestjs/mapped-typesパッケージからインポートされている。PickType()暗数は、入力した型からプロパティのセットを選んで、新しい型(クラス)を構築する。例えば次のような型から始めるとしよう。

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

PickType()ユーティリティ関数を使って、このクラスからプロパティのセットを選ぶことができる。

export class UpdateCatAgeDto extends PickType(CreateCatDto, ['age'] as const) {}

HINT
PickType()関数は@nestjs/mapped-typesパッケージからインポートされている。OmitType()関数は、入力型からすべてのプロパティを抽出し、特定のキーセットを削除して型を構築する。例えば入力としてこんな型を考えてみよう。

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

以下に示すように、name以外の全てのプロパティを持つ派生型を生成する事ができる。このコードにおいて、OmitTypeの第2引数はプロパティ名の配列だ。

export class UpdateCatDto extends OmitType(CreateCatDto, ['name'] as const) {}

HINT
OmitType()関数は、@nestjs/mapped-typesパッケージからインポートされている。IntersectionType()関数は2つの型を1つの新しい型(クラス)に結合する。例えば入力としてこんな2つの型を考えてみよう。

export class CreateCatDto {
  name: string;
  breed: string;
}

export class AdditionalCatInfo {
  color: string;
}

両タイプの全てのプロパティを組み合わせた新しい型を生成する事ができる。

export class UpdateCatDto extends IntersectionType(
  CreateCatDto,
  AdditionalCatInfo,
) {}

HINT
IntersectionType()関数は、@nestjs/mapped-typesパッケージからインポートされている。

型マッピングのユーティリティ関数は合成可能だ。たとえば次のようにすると、CreateCatDto型のプロパティのうち、nameを除く全てのプロパティを持つ型(クラス)が生成され、それらのプロパティは省略可能に設定される。

export class UpdateCatDto extends PartialType(
  OmitType(CreateCatDto, ['name'] as const),
) {}

配列の解析と検証

TypeScriptはジェネリックやインターフェイスに関するメタデータを保存していないため、DTOでそれらを使用すると、ValidationPipeが受信データを適切に検証できない場合がある。例えば以下のコードではcreateuserDtosが正しく検証されない。

@Post()
createBulk(@Body() createUserDtos: CreateUserDto[]) {
  return 'This action adds new users';
}

検証するには、配列をラップするプロパティを持つ専用クラスを作成するか、ParseArrayPipeを使用する。

@Post()
createBulk(
  @Body(new ParseArrayPipe({ items: CreateUserDto }))
  createUserDtos: CreateUserDto[],
) {
  return 'This action adds new users';
}

さらに、ParseArrayPipeは、クエリパラメータを解析するときにも便利だ。クエリパラメータとして渡された識別子に基づいてユーザーを返すfindByIds()メソッドを考えてみよう。

@Get()
findByIds(
  @Query('id', new ParseArrayPipe({ items: Number, separator: ',' }))
  ids: number[],
) {
  return 'This action returns users by ids';
}

この構文は、以下のようなHTTP GETリクエストから入力されるクエリパラメータを検証する。

GET /?ids=1,2,3

Websocketsとマイクロサービス

この章ではHTTPスタイルのアプリケーション(ExpressやFastifyなど)を使った例を紹介しているが、ValidationPipeは使用するトランスポートメソッドに関わらずWebSocketやマイクロサービスでも同様に動作する。

詳しく学ぶ

カスタムバリデータ、エラーメッセージ、そしてclass-validatorパッケージで使えるデコレータについての詳細はこちら