Lesson 4

NestJSをより深く知る

Lesson 4 Chapter 1
NestJSをより深く知る

Lesson 4ではNestJSをより深く知るためにNestJSのより多くの機能を学んでいきます。

ここまでTodoアプリの実装をNestJSの基本機能を使って作ってきました。これから学んでいくことはそのTodoアプリをよりよいものにするための機能です。この機能を使うことでTodoアプリがよりリッチになったり開発がより行いやすくなります。

実務ではNestJSの基本機能だけではなく、NestJSのより多くの機能を使って開発を行います。そのため、ここから学んでいくことは実務に近い知識を学ぶことにも繋がります。

また、NestJSのバックエンドフレームワークである所以もこのLessonでより明確になっていきます。NestJSをより深く知っていくことは勉強にもなり、何より楽しいです。

それでは「NestJSをより深く知る」Lessonへ入っていきましょう。

Lesson 4 Chapter 2
共通処理の実装

このChapterではミドルウェアという概念を通して共通処理の実装を学びます。

ミドルウェア

middleware(ミドルウェア)とは「共通処理」を行う関数のことです。「共通処理」は「リクエストとレスポンス」の処理間で発生します。正確にはルートハンドラーが実行される前にミドルウェア関数が実行されます。また、ミドルウェアはnextという引数を取ります。nextには次のミドルウェアが含まれます。nextを実行して次のミドルウェアに処理を移すことができます。さらに下記図を見ての通りミドルウェアは複数実行できます。

middleware_ch4.2.1.png ミドルウェア

ミドルウェアのユースケース

ミドルウェアのユースケースの例としてロガーが挙げられます。ロガーは一定のタイミングで定期的にログを出力する関数です。すべてのリクエストに対してロガーを出力することを考えてみます。その場合、コントローラーすべてのハンドラーに対してconsole.logもしくはそれをラップした関数を挟み込む必要が出てきます。ミドルウェアであれば一度定義してしまえばあるゆる箇所でそれが適用され何度も同じ処理を書く必要がありません。

ミドルウェアの実装

簡単なロガー用ミドルウェアを実装します。ロガーではリクエストメソッドとリクエストパスを出力しましょう。まずはディレクトリとファイルを作成します。

~/src/(Mac, Linux)
mkdir middlewares && cd middlewares
touch logger.middleware.ts
~/src/(Windows)
mkdir middlewares && cd middlewares
type logger.middleware.ts

ロガーミドルウェアの実装

ミドルウェアはクラスと関数どちらでも実装できます。公式ではシンプルな関数ミドルウェアを推奨しているので関数ミドルウェアを実装します。まずは簡単な文字列を出力するだけのロガーミドルウェアを実装します。最後に必ずnextを実行しましょう。

~/src/middlewares/logger.middleware.ts
import { Request, Response, NextFunction } from 'express';

export const logger = (req: Request, res: Response, next: NextFunction) => {
  console.log(`Request...`);
  next();
};

ミドルウェアの登録

ミドルウェアを実装したのでアプリケーションに登録します。ミドルウェアの登録は「app.module.ts」にて行います。AppModuleconfigureメソッド内でミドルウェアを登録します。forRoutesでルートを指定できますが今回はすべてのリクエストで行うため/としています。

~/src/app.module.ts
@Module({
  ...省略
})
// 追加
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(logger).forRoutes('/');
  }
}

ミドルウェアの実行

リクエストを送信してミドルウェアを実行します。ログに指定した文字列が表示されれば成功です。「http://localhost:3090/todo/」へ対してリクエストを送信します。下記のようにログに表示されたでしょうか?

ターミナル
Request... 

リクエストログ用のミドルウェア

最後にリクエスト情報をログとして出力するミドルウェアを作成します。情報としてはリクエストメソッドとリクエストURLを表示するようにしてみましょう。先ほどのミドルウェアをアップデートします。

~/src/middlewares/logger.middleware.ts
...省略
export const logger = (req: Request, res: Response, next: NextFunction) => {
  // 変更
  console.log(`${req.method.toUpperCase()}: ${req.url}`);
  next();
};

「http://localhost:3090/todo/」へリクエストを送信すると下記のように表示されます。

ターミナル
POST: /todo

まとめ

今回はログ用のミドルウェアを実装しましたが、ライブラリとして公開されているミドルウェアも多々存在します。たとえば代表的なWebセキュリティ攻撃のXSS(クロスサイトスクリプティング)を防止するhelmet等のライブラリはミドルウェアを公開しており、リクエストごとに実行する必要があります。このようにミドルウェアは複数呼ぶことが可能です。通常のプロジェクトでは複数のミドルウェアを実行しながらさまざまな共通処理を実行していくことになります。

Lesson 4 Chapter 3
特定のルーティングで実行する

「Chapter4.2 共通処理の実装」ではすべてのルーティングに対してミドルウェアが実行される仕組みにしました。ミドルウェアは特定のルーティングのみで実行することもできます。

特定のルーティングで実行する:forRoutes

「Chapter4.2 共通処理の実装」ではconsumer.apply(logger).forRoutes('/');によってミドルウェアの登録を行いました。forRoutesの引数によってミドルウェアの呼び出しを特定のルートに制限できます。たとえば「/todo」のみで実行したいミドルウェアが存在するときは下記のようにcontrollerを登録します。

consumer.apply(logger).forRoutes(TodoController);

特定のルートを除外する:exclude

excludeを使用することで特定のルートを除外できます。「/todo」をforRoutesに登録した状態でTodo削除のハンドラーをロガーから除外してみます。「http://localhost:3090/todo/TODOのID」へDELETEリクエストを送っても何もログが表示されないことを確認できます。excludeにオブジェクトで指定する場合はpathmethod が必須パラメーターとなります。

app.modules.ts
...省略

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(logger)
      // DELETEメソッドをミドルウェアから除外
      .exclude({
        path: 'todo/:id',
        method: RequestMethod.DELETE,
      })
      .forRoutes(TodoController);
  }
}

まとめ

ミドルウェアは特定のルートに制限して実行できることと特定のルートを除外して実行できることが確認できました。どちらもミドルウェアを使用する上で必須な概念となりますのでしっかり復習しておきましょう。

Lesson 4 Chapter 4
ミドルウェアを複数呼び出す

ミドルウェアを同時に複数呼び出すことが可能です。まずは必要なライブラリのインストールを行います。

helmet

helmetはWebの脆弱性からアプリケーションを守ってくれるライブラリです。具体的にはレスポンスヘッダー内で必要な情報を付与、不要な情報を削除します。下記でインストールを行いましょう。

~/src
npm i helmet

cors

corsとはオリジン間リソース共有 (Cross Origin Resource Sharing)のことを言います。 この機能によってオリジン間のリソース共有を制限・許可できます。 通常、http://localhost:3000/searchからhttp://localhost:3090/searchへリクエストを送る場合、 ブラウザはcorsによってこのリクエストをブロックします。この場合、http://localhost:ポート番号/までがオリジンにあたります。このリクエストを許可するために以下の処理が必要になります。

~/src/middlewares/cors.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class CorsMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    res.header('Access-Control-Allow-Origin', '*');
    res.header(
      'Access-Control-Allow-Headers',
      'Origin, X-Requested-With, Content-Type, Accept',
    );
    next();
  }
}

現在のレスポンスヘッダー情報

ミドルウェアを複数設定する前に現在のレスポンスヘッダーがどのようになっているか確認してみます。「http://localhost:3090/todo/」へGETリクエスを送信し、ThunderClientのHeadersを確認してみます。「x-powered-by」というヘッダーが含まれています。このヘッダー情報は脆弱性に関わる情報を含みMDNでも含めないよう警告されていますので消す必要があります。

thunder_ch4.4-1.png レスポンスヘッダー情報

helmetミドルウェアの適用

冒頭でインストールしたhelmetをミドルウェアとして登録します。

~/src/app.module.ts
...省略
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      // 追加
      .apply(helmet())
      // 追加
      .forRoutes('/')
      .apply(logger)
      .exclude({
        path: '/todo/:id',
        method: RequestMethod.DELETE,
      })
      .forRoutes(TodoController);
  }
}

レスポンスヘッダー情報

helmet適用後レスポンスヘッダーを確認してみます。同様に「http://localhost:3090/todo/」へGETリクエストを送信します。レスポンスヘッダー情報は下記になります。たくさん追加されていますが、「x-powered-by」が含まれてないことが確認できます。

thunder_ch4.4-2.png helmet適用後レスポンスヘッダー

corsミドルウェアの適用

作成したcorsミドルウェアを登録します。corsのミドルウェア適用後は「access-control-allow-origin」と「access-control-allow-headers」が追加されることを確認します。

~/src/app.module.ts
...省略
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      // 追加
      .apply(helmet(), CorsMiddleware)
      .forRoutes('/')
      .apply(logger)
      .exclude({
        path: '/todo/:id',
        method: RequestMethod.DELETE,
      })
      .forRoutes(TodoController);
  }
}

レスポンスヘッダー情報

cors適用後レスポンスヘッダーを確認してみます。同様に「http://localhost:3090/todo/」へGETリクエストを送信します。レスポンスヘッダー情報に「access-control-allow-origin」と「access-control-allow-headers」が追加されたことが確認できます。現在は「access-control-allow-origin」が*となっておりどこからでもリクエストができる状態になっています。実際の開発ではこの値を制限してリクエスト元を制限します。

thunder_ch4.4-3.png レスポンスヘッダー情報

まとめ

セキュリティ関連のミドルウェアを適用することが複数のミドルウェアを実行できることが確認できました。helmetとcorsはNodejsを用いて開発する際ほとんどのプロジェクトで使用されます。つまり複数のミドルウェア実行は必須の知識となりますのでしっかり復習を行いましょう。

Lesson 4 Chapter 5
グローバルに定義する(ミドルウェア)

「Chapter 4 ミドルウェアを複数呼び出す」ではforRoutes('/')を指定してミドルウェアを呼び出す方法を学びました。このChapterでは「Chapter 4.4 ミドルウェアを複数呼び出す」で設定したミドルウェアをforRoutesなしでグローバルに登録する方法を学びます。

ミドルウェアをグローバルに定義する

ミドルウェアをグローバルに定義するということは、「登録されているすべてのハンドラーに対してミドルウェアを実行する」ということです。グローバル定義を行うにはNestJSのインスタンスが提供するuseメソッドを使用します。

~/src/main.ts
import { NestFactory } from '@nestjs/core';
// 追加
import helmet from 'helmet';

import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 追加
  app.use(helmet());
  await app.listen(3090);
}
bootstrap();

まとめ

登録後「http://localhost:3090/todo/」へGETリクエストを行うとforRoutesでhelmetミドルウェアを登録した時と変わらないレスポンスヘッダー情報が確認できました。これでミドルウェアのグローバル定義が完了です。

Lesson 4 Chapter 6
オリジナルのデコレーターを作る

NestJSには多くのデコレーターが存在しますが、ユーザー定義のオリジナルデコレーターも作成できます。オリジナルデコレーターを作成する目的はアプリケーションのロジックをより宣言的に表現することです。この章ではオリジナルデコレーターを作成し、それを使用してアプリケーションのロジックをより宣言的に表現する方法を学びます。

ロジックを宣言的に記述する

デコレーターを使用してロジックを宣言的に記述するとはどういうことか見ていきます。

RequestオブジェクトからユーザーIDを抽出する

たとえば多くのリクエストでユーザーIDを抽出しそれを使用したいケースがあるとします。ユーザーIDを抽出するためにはreqオブジェクトからuserオブジェクトを取得し、その中からidを取得するという手続きを踏む必要があります。以下のコードはその例を示しています。書く必要はありません。

ユーザーIDを取得する例
@Get(':id')
async myController(
  @Request req: Request,
): Promise<MyDto> {
  const userId = req.user.id;
  return await this.myService.myMethod(userId);
}

デコレーターで宣言的にユーザーIDを抽出する

上記コードは以下のように書き換えることができます。オブジェクトからuserIdを取得するという手続きを踏むことなくuserIdを取得できています。あたかもuserIdがそのまま引数に入ってくるように宣言的な記述で実現できました。

ユーザーIDを取得する例(デコレーター書き換え後)
@Get(':id')
async myController(
  @GetTodoId() userId: string, // 変更
): Promise<MyDto> {
  return await this.myService.myMethod(userId);
}

オリジナルデコレーター(カスタムデコレーター)

デコレーターを作成するにはcreateParamDecorator関数を使用します。この関数を使用してデコレーターを作成します。

ディレクトリとファイルを作成する

デコレーターを作成するためsrc/直下にdecoratorsディレクトリを作成します。

~/src/(Mac, Linux)
mkdir decorators && cd decorators
touch todo.decorator.ts
~/src/(Windows)
mkdir decorators && cd decorators
type todo.decorator.ts

デコレーターを作成する

以下のコードはGetTodoIdデコレーターを作成するコードです。src/decorators/todo.decorator.tsに記述します。このデコレーターは@GetTodoId()という形で使用します。

~/src/todo/todo.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const GetTodoId = createParamDecorator(
  (data: unknown, ctx: ExecutionContext): string => {
    const request = ctx.switchToHttp().getRequest();
    return request.params.id as string;
  },
);

デコレーターを使用する

以下のコードはGetTodoIdデコレーターを使用するコードです。デコレーターを使用すると手続きを踏むことなく宣言的todoIdを取得できます。

~/src/todo/todo.controller.ts
...省略

@Delete(':id')
async deleteTodo(
  @Param() param: DeleteTodoRequestDTO,
  @GetTodoId() id: string,
): Promise<DeleteTodoResponseDTO> {
  console.log('id: ', id);
  return await this.todoService.deleteTodo(param.id);
}

...省略

まとめ

デコレーターを使用することで手続きを踏むことなく宣言的todoIdを取得できました。デコレーターを使用することでコードの可読性が向上し、コードの記述量が減ります。今後のChapterで使用するguardinterceptorもデコレーターを使用して実装します。

Lesson 4 Chapter 7
認可処理の実装

このChapterでは認可処理の実装を行います。認可処理とは、ユーザーがリソースに対してアクセスできるかどうかを判定する処理です。認可処理はNestJSguardという機能を使用して実装します。このChapterではguardを使用して認可処理を実装するところまでを行います。

実装の方針

認可にはほとんど認証が伴います。しかし、認証処理の実装は難しく解説も多くなってしまいます。今回は認可処理(guard)の解説という本質に集中するため、認証処理は実装せずに認可のための仮実装を行います。

認可のための仮実装

今回の認可処理のフローとしては下記になります。

  1. クライアントがリクエストを送信する
  2. NestJSでリクエストを受け取り、guardを使用して認可処理を行う
  3. 認可処理が通ればそのまま通常のリクエスト処理を実施する
  4. 認可処理が通らなければ403エラーが返る

認可の条件

今回の認可の条件としてリクエストヘッダーにロールを所持していることと定義します。また認可処理が通る条件としては、ハンドラーが持っているロールとユーザーが所持しているロールが合致していることとします。 イメージが湧かないと思うので早速実装していきましょう。

認可処理の実装

認可処理を実装するにはguardを使用します。

ディレクトリとファイルを作成する

認可処理を実装するためsrc/直下にguardsディレクトリを作成します。

~/src/(Mac, Linux)
mkdir guards && cd guards
touch roles.guard.ts
~/src/(Windows)
mkdir guards && cd guards
type roles.guard.ts

ロールを定義する

アプリケーションの中で使用するロールを事前に定義しておきましょう。この工程を行うことでハードコーディングを避けることができ、人的ミスの防止につながります。

ハードコーディング

ハードコーディングとは、ソースコードに直接値を記述することを指します。ハードコーディングを行うと、ソースコードの可読性が下がり、コードの修正が難しくなります。

~/src/constants/index.ts
export const TODO_IMAGE_FILE_PATH = './files';
export const BASE_URL = 'http://localhost:3090';

// 以下を追加
export const META = {
  roles: {
    admin: 'admin',
  },
};

ハンドラーにロールを付与する

認可処理を実装するためにはハンドラーにロールを付与する必要があります。今回はTodoControllerfindAllメソッドにロールを付与します。ハンドラーにロールを付与するためには@SetMetadataデコレーターを使用します。@SetMetadataデコレーターを使用することでハンドラーにメタデータを付与できます。

~/src/todo/todo.controller.ts
...省略
import { BASE_URL, META, TODO_IMAGE_FILE_PATH } from 'src/constants';
...省略

@Controller('todo')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}
  @Get()
  @SetMetadata('roles', [META.roles.admin]) // 追加
  async findAll(): Promise<FindAllTodoResponseDTO> {
    return await this.todoService.findAll();
  }

  ...省略
}

ロール付与用の関数をモジュール化する

毎回@SetMetadata('roles', ~)と書くのはミスも生じるためこちらをモジュール化しましょう。デコレーターとして使用したいのでカスタムデコレーターを定義します。

~/src/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { META } from 'src/constants';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

ロール付与用の関数を使用する

先ほど作成したRoles関数を使用してハンドラーにロールを付与します。

~/src/todo/todo.controller.ts
...省略
import { Roles } from 'src/decorators/roles.decorator'; // 追加
...省略

@Controller('todo')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}
  @Get()
  @Roles(META.roles.admin) // 追加
  async findAll(): Promise<FindAllTodoResponseDTO> {
    return await this.todoService.findAll();
  }

  ...省略
}

ガードとは

ガードの役割は認可を行うことたった1つです。認可は、ユーザーがリクエストを送信した際に、そのリクエストを許可するか拒否するかを決定する処理です。リソースとはこの場合付与するハンドラーに該当します。

ガードを実装する

src/guards/roles.guard.tsにガードを実装します。ガードは@Injectable()デコレータで装飾されたクラスであり、canActivateメソッドの実装(implements)が必須です。reflectorを使用してメタデータにアクセスします。 また、ガードは付与されているハンドラーのコンテキストを取得できます。コンテキストを取得することで、ハンドラーに付与されているロールを取得できます。

~/src/guards/roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    // 付与されているハンドラーのロールを取得
    const handlerRoles = this.reflector.get<string[]>(
      'roles',
      context.getHandler(),
    );

    // ロールが付与されていない場合は認可を行わずすべてのリクエストを許可
    if (!handlerRoles) {
      return true;
    }

    // リクエストのロールを取得
    const request = context.switchToHttp().getRequest();
    const userRole = request.get('role');

    // リクエストのロールがハンドラーのロールに含まれているかを返す
    return handlerRoles.some((role) => userRole === role);
  }
}

まとめ

今回は認可を行うガードを実装しました。次のChapterで実装したガードを元にハンドラーへ付与し認可処理を行っていきます。

Lesson 4 Chapter 8
特定の関数に定義する

このChapterでは特定の関数(ハンドラー)にガードを付与する方法を学びます。ガードは前回のChapterで実装したRolesGuardを使用します。Todoの一覧を取得するハンドラーにガードを付与します。

特定の関数に定義する

ガードを特定の関数に定義するには、@UseGuards()デコレータを使用します。

~/src/todo/todo.controller.ts
...省略
import { RolesGuard } from 'src/guards/roles.guard'; // 追加
...省略

@Controller('todo')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}
  @Get()
  @Roles(META.roles.admin)
  @UseGuards(RolesGuard) // 追加
  async findAll(): Promise<FindAllTodoResponseDTO> {
    return await this.todoService.findAll();
  }

  ...省略
}

ガードの実装を確かめる

実装したガードがちゃんと動くのか確かめてみましょう。ガード実装の詳細を振り返ってみるとconst userRole = request.get('role');という記述があります。こちらはリクエストヘッダーからroleというキーを用いて値を取得する処理です。よってリクエストヘッダーにroleというキーで値をセットする必要があります。

認可が動作するか確かめる

最初は認可処理が正常に動作しているか確かめるためにわざと認可に通らない状況でリクエストを送信してみます。ThunderClientのヘッダーを初期状態でリクエストを送信してみましょう。

thunder_ch4.4.2-1.png わざと認可を通らないリクエスト送信

すると以下のjsonが返されます。こちらはNestJSの機能であるExceptionFilterが働いた結果返されます。今回はその中の1つのUnauthorizedExceptionが返された結果です。

ExceptionFilter

ExceptionFilterにはたくさん種類があるので興味がある方はこちらをのぞいてみてください。

Todo一覧レスポンス
{
  "statusCode": 403,
  "message": "Forbidden resource",
  "error": "Forbidden"
}

認可を通すリクエストを送信する

次に認可を通すリクエストを送信してみます。ThunderClientのヘッダーにroleというキーで「admin」という値をセットしましょう。リクエストを送信すると通常通りTodoの一覧が返却されることを確認できます。これで認可が正常に動作していることを確認できました。

thunder_ch4.4.2-2.png 認可を通すリクエスト送信

まとめ

このChapterでは特定の関数にガードを付与する方法を学びました。次はグローバルにガードを適用させる方法を学びます。

Lesson 4 Chapter 9
グローバルに定義する(ガード)

このChapterではガードをグローバルに適用する方法を学びます。前回は特定のハンドラーのみに付与したため、Todo一覧のハンドラーにのみガードが適用されていました。グローバルにガードを適用することで他のハンドラーでもガードが動作するか確かめていきましょう。

グローバルにガードを定義する

まずはapp.module.tsにガードを定義します。今回はRolesGuardをグローバルに適用させます。そのためにはapp.module.ts@ModuleデコレーターのprovidersRolesGuardを追加します。

app.module.ts
...省略
import { RolesGuard } from './roles.guard';
...省略

@Module({
  imports: [
    ...省略
    // 追加
    providers: [
      {
        provide: APP_GUARD,
        useClass: RolesGuard,
      },
    ],
  ],
})

リクエストを送信する

グローバルに定義したガードが動作しているか確かめてみます。「Chapter4.4.2 特定の関数に定義する」と同じようにTodo一覧取得リクエストを送信してみましょう。その際に以下の画像のようにroleのチェックは外しておきます。ステータスコード403UnauthorizedExceptionの内容が返却されれば認可処理が通っていることを確認できます。

thunder_ch4.4.3-1.png 認可を通さないリクエスト送信

Todo作成用のハンドラーにロールを付与する

グローバルに適用しているため他のハンドラーにも認可処理が働くか確かめてみましょう。まずはTodo作成用のハンドラーにメタデータを付与します。

~/src/controllers/todo/todo.controller.ts
...省略

@Controller('todo')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}

  ...省略

  @Post()
  @Roles(META.roles.admin) // 追加
  @UseInterceptors(
    FileInterceptor('file', {
      storage: diskStorage({
        destination: TODO_IMAGE_FILE_PATH,
        filename: generateFilename,
      }),
    }),
  )
  async createTodo(
    @Body() createTodoDTO: CreateTodoRequestDTO,
    @UploadedFile() file: Express.Multer.File,
  ): Promise<CreateTodoResponseDTO> {
    const imagePath = file?.path ? `${BASE_URL}/${file.path}` : null;
    return await this.todoService.createTodo(createTodoDTO, imagePath);
  }

  ...省略
}

Todo作成用のハンドラーにリクエストを送信する

Todo作成用のハンドラーにメタデータを付与したので、リクエストを送信してみましょう。その際に以下の画像のようにroleは付与せず送信します。ステータスコード403UnauthorizedExceptionの内容が返却されれば認可処理が通っています。

thunder_ch4.4.3-2.png 認可を通さないリクエスト送信

まとめ

このChapterではガードをグローバルに適用する方法を学びました。前回は特定のハンドラーのみに付与したため、Todo一覧のハンドラーにのみガードが適用されていました。グローバルにガードを適用することで他のハンドラーでもガードが動作するか確かめることができました。

Lesson 4 Chapter 10
pipeを用いたバリデーション実装

このChapterではバリデーションを実装する方法を学びます。バリデーションを実装することで、emailやパスワードの形式チェック等でリクエストの内容が正しいかどうかを確認し、不正なリクエストを受け付けないようにすることができます。NestJSではPipeという機能を用いてバリデーションを行います。

Pipe

Pipeはハンドラーがリクエストを受け取る前にリクエストに対して処理を行います。Pipeでは以下のことが可能で、処理を行った後のデータをハンドラーに渡します。

説明
変換 入力データを希望の形に変換する(例:文字列→整数)
バリデーション 入力データを評価し、データが正しければそのまま実行を続け、データが間違っていれば例外をthrowする

組み込みのPipe

NestJSには組み込みのPipeが用意されています。以下のPipe@nestjs/commonからインポートできます。

名前 説明
ValidationPipe バリデーションを行う
ParseIntPipe 文字列を整数に変換する
ParseBoolPipe 文字列を真偽値に変換する
ParseArrayPipe 文字列を配列に変換する
ParseUUIDPipe 文字列をUUID型であるかバリデーションを行う
DefaultValuePipe 入力がnullまたはundefined時にデフォルト値を付与する

Pipeの適用方法

Pipeはハンドラーの引数に付与することで適用できます。以下の例ではValidationPipeをハンドラーの引数に付与しています。

ValidationPipeをハンドラーの引数に付与することで、createTodoDtoに対してバリデーションが行われます。

ハンドラへの適用(例)
@Post()
@UsePipes(ValidationPipe)
create(@Body() createTodoDto: CreateTodoDto) {
  return this.todoService.create(createTodoDto);
}

また、パラメーターへの適用も可能です。パラメーターごとに型が異なるためより柔軟にすることができます。

パラメーターごとへの適用(例)
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  return this.todoService.findOne(id);
}

また、グローバルへの適用も可能です。グローバルに適用することで、すべてのハンドラーに対してValidationPipeが適用されます。

グローバルへの適用(例)
app.useGlobalPipes(new ValidationPipe());

pipeを用いたバリデーション実装

ここではParseUUIDPipeを用いてバリデーションを実装します。Todo詳細取得ハンドラーのfindOneメソッドを以下のように修正します。

~/src/controllers/todo/todo.controller.ts
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseUUIDPipe, //追加
  Post,
  Put,
  UploadedFile,
  UseInterceptors,
} from '@nestjs/common';

...省略

  @Get(':id')
  async findOne(
    @Param('id', ParseUUIDPipe) { id }: FindOneTodoRequestDTO,
  ): Promise<FindOneTodoResponseDTO> {
    return await this.todoService.findOne(id);
  }

...省略

リクエストを送信する

ParseUUIDPipeのバリデーションが働くか確認するためにリクエストを送信して確かめてみます。以下のようにThunderClientからリクエストを送信します。リクエストパラメーターのidの部分をわざとUUID以外の文字列にして送信してみます。

thunder_ch4.5.1-1.png ThunderClient Todo詳細取得

バリデーションエラーを確認する

上記のようにリクエストを送信すると、以下のようにバリデーションエラーが返却されます。よって、ParseUUIDPipeのバリデーションが働いていることが確認できます。

ThunderClient レスポンス
{
  "statusCode": 400,
  "message": "Validation failed (uuid is expected)",
  "error": "Bad Request"
}

まとめ

ここではPipeを用いてバリデーションを実装する方法を学びました。次のChapterからはclass-validatorを用いてバリデーションを実装する方法を学びます。class-validatorを使用するとDTOに対してバリデーションを実装することができます。

Lesson 4 Chapter 11
class-validatorのインストール

class-validator

class-validatorは入力バリデーション用のライブラリです。DTOクラスに対して付与することでバリデーションを実装することができます。class-validatorのGitHubで用意されてるバリデーションを確認できます。

class-validatorのインストール

まずはclass-validatorをインストールします。class-validatorの使用にはclass-transformerも必要なので一緒にインストールします。インストールするためにプロジェクト直下へ移動しましょう。

~/nest-todo
npm i class-validator class-transformer

まとめ

ここではclass-validatorのインストール方法を学びました。次のChapterからはclass-validatorを用いてバリデーションを実装する方法を学びます。

Lesson 4 Chapter 4.5.3
class-validatorを用いたバリデーション実装

ここではclass-validatorを用いてバリデーションを実装します。

class-validatorの実装

まずはclass-validatorを用いてFindOneTodoRequestDTOにバリデーションを実装します。@IsUUID()を付与することでidにUUIDのバリデーションを実装できます。

~/nest-todo/src/todo/dto/find-one-todo-request.dto.ts
import { IsUUID } from 'class-validator';

export class FindOneTodoRequestDTO {
  @IsUUID()
  id: string;
}

Pipeのグローバル適用

ここまででFindOneTodoRequestDTOにバリデーションを実装しました。しかし、現状ではFindOneTodoRequestDTOにバリデーションを実装しても、FindOneTodoRequestDTOを使用しているfindOneTodoにバリデーションが適用されていません。そのため、findOneTodoにバリデーションを適用するためには、FindOneTodoRequestDTOを使用しているfindOneTodo@UsePipesを付与する必要があります。しかし、毎回ハンドラーに対して@UsePipesを付与するのは面倒なので、Pipeをグローバルに適用します。

~/nest-todo/src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import helmet from 'helmet';

import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(helmet());
  app.useGlobalPipes(new ValidationPipe()); // 追加
  await app.listen(3090);
}
bootstrap();

リクエストを送信する

ここまででFindOneTodoRequestDTOにバリデーションを実装し、Pipeをグローバルに適用しました。次にThunderClientでリクエストを送信してバリデーションが適用されているか確認してみましょう。

thunder_ch4.5.1-1.png ThunderClientでリクエストを送信

レスポンスを確認する

「リクエストを送信する」の結果以下のようにレスポンスが返却されているとclass-validatorを用いたバリデーションが成功しています。

ThunderClientレスポンス
{
  "statusCode": 400,
  "message": [
    "id must be a UUID"
  ],
  "error": "Bad Request"
}

各DTOにバリデーションを付与していく

ここまででFindOneTodoRequestDTOにバリデーションを実装しました。他のDTOにもバリデーションを実装していきます。

CreateTodoRequestDTO

CreateTodoRequestDTOでは以下のバリデーションを利用します。

バリデーション 説明
@IsString() 文字列であることを確認する
@IsNotEmpty() 空文字でないことを確認する
@MaxLength(256) 最大文字数を256文字に設定する
@IsBoolean() 真偽値であることを確認する
@IsOptional() 空文字であっても良いことを確認する
~/nest-todo/src/requests/todo/create.dto.ts
import {
  IsBoolean,
  IsNotEmpty,
  IsOptional,
  IsString,
  MaxLength,
} from 'class-validator';

export class CreateTodoRequestDTO {
  @IsString()
  @IsNotEmpty()
  @MaxLength(256)
  title: string;

  @IsBoolean()
  @IsOptional()
  isCompleted?: boolean;
}

たとえばバリデーションに反したリクエストを送信すると下記のようにバリデーションメッセージが返却されます。

Thunder Clientレスポンス
{
  "statusCode": 400,
  "message": [
    "title must be shorter than or equal to 256 characters",
    "title should not be empty",
    "title must be a string"
  ],
  "error": "Bad Request"
}

UpdateTodoRequestDTO

UpdateTodoRequestDTOでもCreateTodoRequestDTOと同じバリデーションを利用します。

~/src/requests/todo/update.dto.ts
import {
  IsBoolean,
  IsNotEmpty,
  IsOptional,
  IsString,
  MaxLength,
} from 'class-validator';

export class UpdateTodoRequestDTO {
  @IsString()
  @IsOptional()
  @IsNotEmpty()
  @MaxLength(256)
  title?: string;

  @IsBoolean()
  @IsOptional()
  isCompleted?: boolean;
}

DeleteTodoRequestDTO

DeleteTodoRequestDTOでは@IsUUID()を利用します。

~/src/requests/todo/delete.dto.ts
import { IsUUID } from 'class-validator';

export class DeleteTodoRequestDTO {
  @IsUUID()
  @IsNotEmpty()
  id: string;
}

まとめ

ここまででclass-validatorを用いたバリデーションを実装しました。DTOに対してバリデーションを実装することで、DTOではリクエストオブジェクトの定義と同時にバリデーションを隠蔽することができ、controllerの記述をよりシンプルに保つことができるようになりました。

Lesson 4 Chapter 12
共通エラー処理の実装(Interceptor)

このChapterではエラー処理を行いながらInterceptorについて学習します。 Interceptorとは、リクエストの前後に処理を挟むことができるNestJSの機能です。 Interceptorを利用することで、ハンドラーの直後にロジックを追加できたり、関数のレスポンスを変換したり、例外処理を行ったりさまざまなことができます。

Interceptorの基本

InterceptorNestInterceptorを実装することで作成できます。以下はロギングを行うInterceptorの例です。Interceptorは必ずinterceptメソッドを持ちます。interceptはリクエストとレスポンスの結果を受け取る事ができるため付与するハンドラー本体の実行前後に処理を挿入する事ができます。第1引数のcontextExecutionContextで、リクエストの情報を保持しています。第2引数のnextCallHandlerで、ハンドラーの実行を行うためのメソッドを持っています。下記実装の場合Before...がリクエストの前に、After... \${Date.now() - now}msがレスポンスの後に出力されます。

~/src/interceptors/logging.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable {
    console.log('Before...');
    const now = Date.now();
    return next.handle().pipe(
      tap(() => console.log(\`After... \${Date.now() - now}ms\`)),
    );
  }
}

Lesson 4 Chapter 13
例外処理の実装(catchError)

このChapterではInterceptorを使用して例外処理の実装を学びます。

Interceptorを使った例外処理

Interceptorを使用することで、共通の例外処理をハンドラーに適用できます。今回は途中で例外がthrowされた場合そのままthrowし、サーバーエラーが発生した場合は例外フィルターを返すような実装を行います。catchErrorthrowされたエラーオブジェクトを捕捉してInterceptor内で操作できるようにしてくれます。

~/src/interceptors/errors.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  HttpException,
  Injectable,
  InternalServerErrorException,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, catchError, throwError } from 'rxjs';

@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable {
    return next.handle().pipe(
      catchError((err) => {
        console.log('err: ', err); // ログを監視してInterceptorによって例外が投げられているか確認
        if (err instanceof HttpException) {
          return throwError(() => err);
        }
        return throwError(() => new InternalServerErrorException());
      }),
    );
  }
}

エラーを発生させる処理を追加

重複チェック repository

今回はTodo作成時に同じ名前のTodoが既に存在すれば例外をthrowするようにしましょう。Todo用のrepositoryに処理を追加します。repositoryの処理にはtiteをキーにTodoを取得し、Todoオブジェクトが取得できればboolean型で結果を返すようにします。

~/src/repositories/todo/todo.repository.ts
...省略

  async getTodoDetail(id: string): Promise<ReturnTodoType> {
    return await this.findOne({
      select: SELECTED_COLUMN,
      where: { id },
    });
  }

  // 下記処理を追加
  async checkTodoExists(title: string): Promise<boolean> {
    const todo = await this.findOne({
      select: SELECTED_COLUMN,
      where: { title },
    });
    return !!todo;
  }

  async createTodo(
    createTodoRequestDTO: CreateTodoRequestDTO,
    imagePath: string,
  ): Promise<ReturnTodoType> {
    ...省略
  

重複チェック service

serviceではrepositoryからの結果をもとにエラーをthrowします。

~/src/services/todo/todo.service.ts
...省略
  async createTodo(
    createTodoDTO: CreateTodoRequestDTO,
    imagePath: string,
  ): Promise {
    const isExists = await this.todoRepository.checkTodoExists(
      createTodoDTO.title,
    );

    if (isExists) {
      throw new Error('Todo title already exists');
    }

    const newTodo = await this.todoRepository.createTodo(
      createTodoDTO,
      imagePath,
    );
    return new CreateTodoResponseDTO(newTodo);
  }

まとめ

今回はInterceptorを使用して例外処理を実装しました。次のChapterでは定義したInterceptorcontrollerに適用させていきます。

Lesson 4 Chapter 14
特定の関数に定義する(UseInterceptors())

このChapterでは定義したInterceptorを特定の関数に適用する方法を学びます。

UseInterceptors()

UseInterceptors()は特定の関数(controller全体やcontroller内のハンドラー)に対してInterceptorを適用するためのデコレーターです。引数にはInterceptorを定義したクラスを渡します。

controllerへの適用

今回はTodoControllerErrorsInterceptorを適用します。

~/src/controllers/todo/todo.controller.ts
...省略
import { ErrorsInterceptor } from 'src/interceptors/errors.interceptor'; // 追加

...省略

@UseInterceptors(ErrorsInterceptor) // 追加
@Controller('todo')
export class TodoController {
...省略

Thunder Clientでリクエストの送信

適用できたらThunder ClientからTodo作成のリクエストを送信しましょう。ここではわざと同じ名前のTodoをリクエストパラメーターとして送信し例外を発生させます。その結果下記のようにログに出力されればInterceptor適用の成功です。

thunder_ch4.6.2.png エラーオブジェクトの内容を確認

まとめ

今回はUseInterceptors()を使用して特定の関数にInterceptorを適用する方法を学びました。次のChapterではInterceptorをグローバルに適用させていきます。

Lesson 4 Chapter 15
グローバルに定義する(useGlobalInterceptors)

このChapterでは定義したInterceptorをグローバルに適用する方法を学びます。グローバルインターセプターは全てのコントローラと全てのルートハンドラに対してアプリケーション全体を通して使用されます。

useGlobalInterceptors()

useGlobalInterceptors()Interceptorをグローバルに適用するためのメソッドです。引数にはInterceptorを定義したクラスを渡します。

main.tsへの適用

今回はmain.tsErrorsInterceptorを適用します。適用したら同じようにリクエストを送信してエラーのログが出力されれば適用成功です。

~/src/main.ts
...省略
import { ErrorsInterceptor } from './interceptors/errors.interceptor'; // 追加

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(helmet());
  app.useGlobalPipes(new ValidationPipe());
  app.useGlobalInterceptors(new ErrorsInterceptor()); // 追加
  await app.listen(3090);
}
bootstrap();

...省略


まとめ

今回はuseGlobalInterceptors()を使用してグローバルにInterceptorを適用する方法を学びました。Interceptorを使用することでさまざまな処理が柔軟に実装できるようになりますので積極的に使っていきましょう。