NestJS の DTO と Validation の基本 - 型定義とデコレータで安全にデータを受け付ける


この記事は NestJS Advent Calendar 2019 3 日目の記事です。前日は @euxn23 による Module と DI の話でした。

これまで NestJS のはじめかたと Module と DI の話をしていきました。3 日目となる本日は、もう少し実際の開発に身近な機能である、DTO と Validation について紹介いたします。

tl;dr

  • NestJS にはコアに Request Payload の型定義とバリデーションの機能が備わっている
  • クラスベースの型オブジェクト DTO の定義によって、 Request オブジェクトに触らずに受け付ける型定義ができる
  • DTO にバリデーションを敷くことによって、 Controller のメインロジックより前の段階で不正なアクセスを弾ける

リクエストデータの検証について

Web API を構築する場合、リクエストのデータバリデーションは避けて通れない課題となります。不足したデータがないか、間違ったデータが送られてきていないか、データ型は正しいか。非常に多くの検査項目があります。

@hapi/joiyup、すごくマイナーなところで言えば transform-ts など、データ型の検証をうまく行うためのパッケージは豊富に存在していますが、どれも一長一短ですし、うまく連携させるのは苦労が耐えません。

NestJS では、クラスベースでリクエストデータの型定義を行うしくみ DTO (Data Transfer Object) の上に、デコレータベースのバリデーションライブラリ class-validator を載せる形で、シンプルかつうまく抽象化された単位でバリデーションと不正なリクエストへのレスポンス返却を実現できます。

今回は、はじめに DTO を定義してリクエストデータの型定義と利用について体験したあと、 class-validator と組み合わせたデータ検証を実際に行ってみます。

完成品について

今日もサンプルコードがあります。
先に完成品のコードを見たいかたについては、以下からお願いいたします。

DTO を利用した型定義について

はじめに DTO について、 DAO / DTO の文脈でいう DTO とは少し違います。DAO(Data Access Object) に対する DTO(Data Transfer Object) は、 DAO が取得してきたデータをアプリケーションドメイン上意味のある概念として保持するための意味合いが強いかと思います。

一方 NestJS は、初日にも紹介したように、データストアへのアクセスをコアの関心領域に含んでいません。が、 DTO は存在します。

であればその「データ構造が曖昧で変換対象となる元データを引っ張ってくるもの」つまりは DAO 相当は何かというと、 Request Object になります。

通常の HTTP リクエストは勿論ただの文字列ですから、 Request Payload は string 型であり、それを JSON.parse しているだけなので、中身は any ということになります。

ただ一方でアプリケーション側として欲しい値、来ることを想定している値はある程度決まっているため、それは型情報として定義できます。それが Nest における DTO です。

ざっくりいうとこんな感じです。

  • NestJS の DTO は Request Payload(body) の型定義を行うためのもの
  • 一般的な DAO / DTO の文脈とは少し違った印象を受けるイメージ
  • 型定義と同時にバリデーションまで含むことができる

特に NestJS が大きなアドバンテージを有するのは最後の一点がうまく統合されている点なのですが、それは少し後で見るとして、まずは簡単に DTO を使ってみたいと思います。

DTO を利用してデータを検証する

サンプル作成に入ります。CLI で初期化したばかりのプロジェクトを前提とします。

> npx -p @nestjs/cli nest new day3-dto-and-validation

初日は AppModule をガリガリ触っていきましたが、2日目でモジュールの使い方が紹介されました。ですので、今回は概念ごとにディレクトリを切っていきたいと思います。とはいえこの記事では Service が不要となるため、単純に src/items に items.controller.ts だけ作成します。

折角作るものが少ないので、ここでは CLI を使わず手で書きます。最小限のリクエストを受け取るなら、こんな感じのコードでしょうか。Request Object に必ず Payload が入るような概念が欲しかったので、今回は新規投稿用の POST の受け口を作ってみました。

items.controller.ts
import { Controller, Post } from '@nestjs/common';

@Controller('items')
export class ItemsController {
  @Post()
  createItem(): boolean {
    return true;
  }
}

これができたら、 app.module.ts から呼び出す必要があります。単純にこんな感じです。

app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ItemsController } from './items/items.controller';

@Module({
  imports: [],
  controllers: [AppController, ItemsController],
  providers: [AppService],
})
export class AppModule {}

ここまでできたら、次は DTO を定義します。

DTO の大きな特徴は、 class としてデータ構造を定義することです。これは NestJS がコンパイルされた後もプロパティ情報を保持するためのビルド都合が大きいのですが、とにかく「interface や Type ではなく class で定義する」と覚えておくと OK です。

items.dto.ts
export class CreateItemDTO {
  title: string;
  body: string;
  deletePassword: string;
}

定義できたら Controller から呼び出します。
呼び出す時は @Body によって受け取るデータを定義し、その時に型情報を付与してやると OK です。この時クラスを型として指定してやることによって、 Body はそのクラスのインスタンスと認識されます。

これによって、 interface / Type と同じ書き心地で、型情報が定義されます。

items.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { CreateItemDTO } from './items.dto';

@Controller('items')
export class ItemsController {
  @Post()
  createItem(@Body() createItemDTO: CreateItemDTO) {
    return;
  }
}

実際に補完が効いています。

当たり前な話ですが、クラスを引数の型として設定することにより、 DTO をもとに型の恩恵を享受することができます。req.body などを直で触っているわけではなく、ある程度抽象化された上で渡ってくるので、アドバンテージとしては宣言的なこと。でしょうか。

ひとまずお行儀よく書くことはできそうです。

ただ注意点として、この段階では勿論バリデーションは行われていません。不足したデータなどがあっても、問答無用でリクエストが通ってしまいます。

> http post http://localhost:3000/items title="test"
HTTP/1.1 201 Created
Connection: keep-alive
Content-Length: 4
Content-Type: text/html; charset=utf-8
Date: Mon, 02 Dec 2019 10:58:11 GMT
ETag: W/"4-X/5TO4MPCKAyY0ipFgr6/IraRNs"
X-Powered-By: Express

true

DTO の定義の中でバリデーションする

というわけで型定義だけされている DTO にバリデーション上の意味をもたせていきます。NestJS では、リクエストバリデーション及びエラーメッセージの返却のために、 class-validator および class-transformer パッケージを利用します。

Yarn からインストールします。

> yarn add class-transformer class-validator

続いてアプリケーションのグローバルなオブジェクトに対して、バリデーション時にエラーを返却するための Pipe を追加します。Pipe についてはアドベントカレンダー後半で紹介予定ですが、今は「リクエストに改ざんするプラグイン」と思ってもらえれば OK です。

以下のように追加してください。

main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

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

お膳立ては終わったので、最後にバリデーション定義を書いてみます。今回は全部文字列かつ空文字でなければ良いので、こんな感じで定義するとしましょう。

この点は class-validator の機能をそのまま利用しているだけなので、利用できるデコレーターの種類などは class-validator のレポジトリを御覧ください。

items.dto.ts
import { IsNotEmpty, IsString } from 'class-validator';

export class CreateItemDTO {

  @IsNotEmpty()
  @IsString()
  title: string;

  @IsNotEmpty()
  @IsString()
  body: string;

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

ここまで書けたら実際にリクエストを発行してみます。title だけが存在する状態でリクエストを送ると、以下のように body および detelePassword がないことがエラーとして返却されます。

> http post http://localhost:3000/items title="test"
HTTP/1.1 400 Bad Request
Connection: keep-alive
Content-Length: 304
Content-Type: application/json; charset=utf-8
Date: Mon, 02 Dec 2019 11:02:33 GMT
ETag: W/"130-O4YZqqmJArVWZXjn49s/qiQ5cBw"
X-Powered-By: Express

{
    "error": "Bad Request",
    "message": [
        {
            "children": [],
            "constraints": {
                "isNotEmpty": "body should not be empty"
            },
            "property": "body",
            "target": {
                "title": "test"
            }
        },
        {
            "children": [],
            "constraints": {
                "isNotEmpty": "deletePassword should not be empty"
            },
            "property": "deletePassword",
            "target": {
                "title": "test"
            }
        }
    ],
    "statusCode": 400
}

これでバリデーションが行われた上で、適切なエラーメッセージが表示されるようになりました。

型定義の副産物として生まれたものが、デコレータによってより読みやすい定義となった上、バリデーションにも利用できるので非常に便利かつ他の開発メンバーにも優しい定義方法となっています。

また、非常に大きなアドバンテージとして、Controller に処理が渡ってくる時点で正しいデータであることが保証されているという点も見逃せません。

DTO レイヤーでバリデーションが完了しているため、 Controller では純粋な Controller 処理だけにフォーカスできます。

エラーを抑制したい場合

ちなみに、ここまで詳細なエラーを出したくない場合も往々にしてあるので、そうした場合は以下を参考に適宜設定を変更してください。最小限のエラーにすることができます。

おわりに

3日目の記事では DTO とバリデーションについて紹介しました。

Web アプリケーション開発でありがちな課題を仕組みで解決している点が NestJS の嬉しさな気がします。Request Payload をどのように取り扱うかは勿論、開発者には極力アプリケーションとデータ構造に集中して欲しい様子が伝わってくるのがとても良いですね。

明日は @euxn23 さんによる「注入する Service を差し替える」の紹介となる予定です。