NestJS の Exception Filter を使って例外ベースで異常系の処理を行う

この記事は NestJS Advent Calendar 5日目の記事です。4日目は @euxn23 の「NestJS でダミーの Service を注入し、外部依存のないテストを実行する」でした。

今日までの 4 日で、アプリケーションの立ち上げ、データ構造や URL 構造、リクエストのバリデーションなどを行ってきました。本日はいよいよ Controller でのリクエスト処理に目を向け、 Exception Filter を使った例外ベースでの異常系レスポンスの実装を紹介します。

## 作るもの

1~4日目でやたら掲示板アプリのようなデータ構造を作っているので、今回もいわゆる「スレッド」を立てられるようなエンドポイントを想定します。

## 完成品のサンプルコードについて

今日もサンプルコードがあります。

https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day05-introduce-exception-filter

## Read / Write の実装

Exception Filter の実装に入る前に、まずは Item と Comment の実装を行ってしまいます。昨日と同様に、 CLI から作ります。

```terminal
$ nest new day4-inject-dummy-service
$ yarn add class-transformer class-validator
$ nest g module items
$ nest g controller items
$ nest g service items
```

これまでの Get / Post を再現するため、これらをそのまま実装してください。Get / Post が実装されている状況です。

### main.ts の実装

```ts: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();
```

### Service の実装

```ts:item.service.ts
import { Injectable } from '@nestjs/common';

export interface Item {
  id: number;
  title: string;
  body: string;
  deletePassword: string;
}

const items = [
  {
    id: 1,
    title: 'Item #1',
    body: '#1 body',
    deletePassword: '1234',
  },
];

@Injectable()
export class ItemsService {
  findItemById(id: number): Item | null {
    return items.find(item => item.id === id);
  }

  getItems() {
    return items.map(item => item);
  }

  createItem(title: string, body: string, deletePassword: string) {
    return;
  }
}
```

### DTO の実装

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

export class CreateItemDTO {
  @IsString()
  @IsNotEmpty()
  title: string;

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

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

export class DeleteItemDTO {
  @IsString()
  @IsNotEmpty()
  deletePassword: string;
}
```

### Controller の実装

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

@Controller('items')
export class ItemsController {
  constructor(private readonly itemsService: ItemsService) {}

  @Get()
  async getItems() {
    const items = await this.itemsService.getItems();
    return items;
  }

  @Post()
  async createItem(@Body() { title, body, deletePassword }: CreateItemDTO) {
    const item = await this.itemsService.createItem(
      title,
      body,
      deletePassword,
    );

    return item;
  }
}
```

## Exception Filter について

NestJS には、例外をベースとした異常系レスポンスを行うための機能として Exception Filter を提供しています。今回はアイテムの削除エンドポイントを例に、Exception Filter を体験してみたいと思います。

### 要件と基本的な Exception の生成

現状アイテムのデータ構造から、実装は以下のようになることが想定できるはずです。

1. `items/:itemId` の `:itemId` が存在しない場合にエラーを返す
1. deletePassword が存在しない場合にエラーを返す
1. 処理が失敗した場合は Internal Server Error を返す

まずは Exception Handler を使って、 `1.` を実装してみます。

```ts:items.controller.ts
import {
  Controller,
  Post,
  Get,
  Body,
  Param,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { ItemsService } from './items.service';
import { CreateItemDTO, DeleteItemDTO } from './items.dto';

@Controller('items')
export class ItemsController {
  constructor(private readonly itemsService: ItemsService) {}

  @Get()
  async getItems() {
    const items = await this.itemsService.getItems();

    return items;
  }

  @Post()
  async createItem(@Body() { title, body, deletePassword }: CreateItemDTO) {
    const item = await this.itemsService.createItem(
      title,
      body,
      deletePassword,
    );

    return item;
  }

  @Post(':itemId/delete')
  async deleteItem(
    @Param('itemId') itemId: string,
    @Body() deleteItemDTO: DeleteItemDTO,
  ) {
    const item = this.itemsService.findItemById(+itemId);

    if (!item) {
      throw new HttpException(
        {
          status: HttpStatus.NOT_FOUND,
          error: `Missing item(id: ${itemId}).`,
        },
        404,
      );
    }
    return;
  }
}
```

このコードでは、 deleteItem のロジックに注目してください。対象の itemId がない場合に、 HttpException という例外を投げています。なお、 HTTP DELETE では Payload を付与することができないため、 POST としています。

NestJS では、 `HttpException` を例外として投げることにより、そのままそこに書かれているメッセージとステータスコードを利用して異常系のレスポンスを返却することが可能となっています。また、それによって型の恩恵を受けることや、 Response が抽象化が実現できます。

今回の場合、そもそも対象が見つかっていないので Not Found としました。

### 403 エラーおよび 500 エラーの実装

続いてこの勢いで、パスワード間違いも実装してみます。Service には特定のコントローラーの特定の処理に依存しない流れを書くべきであるため、まずは Service に全ての機能を実装します。

```ts:items.service.ts
import { Injectable } from '@nestjs/common';

export interface Item {
  id: number;
  title: string;
  body: string;
  deletePassword: string;
}

let items = [
  {
    id: 1,
    title: 'Item #1',
    body: '#1 body',
    deletePassword: '1234',
  },
];

@Injectable()
export class ItemsService {
  findItemById(id: number): Item | null {
    return items.find(item => item.id === id);
  }

  getItems() {
    return items.map(item => item);
  }

  createItem(title: string, body: string, deletePassword: string) {
    return;
  }

  async deleteItemByPassword(id: number, deletePassword: string): Promise<void> {
    const targetItem = this.findItemById(id);
    if (!targetItem) {
      return Promise.reject(new Error('Missing Item.'));
    }
    if (targetItem.deletePassword !== deletePassword) {
      return Promise.reject(new Error('Incorrect password'));
    }
    items = items.filter(i => i.id !== targetItem.id);
    return;
  }
}
```

こんな感じでしょうか。実際に RDB などと接続しているプロダクトでは、 `items = items.filter(i => i.id !== targetItem.id);` は `item.delete()` と置き換えていけそうです。

Service 側で実装できたら、 Controller も実装を進めます。パスワードが間違っている場合は削除するだけの権限を満たしていないということで403を、それ以外のエラーの場合はサーバーサイド側のエラーとして 500 を返却するようにしてみます。

```ts:items.controller.ts
import {
  Controller,
  Post,
  Get,
  Body,
  Param,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { ItemsService } from './items.service';
import { CreateItemDTO, DeleteItemDTO } from './items.dto';

@Controller('items')
export class ItemsController {
  constructor(private readonly itemsService: ItemsService) {}

  @Get()
  async getItems() {
    const items = await this.itemsService.getItems();

    return items;
  }

  @Post()
  async createItem(@Body() { title, body, deletePassword }: CreateItemDTO) {
    const item = await this.itemsService.createItem(
      title,
      body,
      deletePassword,
    );

    return item;
  }

  @Post(':itemId/delete')
  async deleteItem(
    @Param('itemId') itemId: string,
    @Body() deleteItemDTO: DeleteItemDTO,
  ) {
    const item = this.itemsService.findItemById(+itemId);

    if (!item) {
      throw new HttpException(
        {
          status: HttpStatus.NOT_FOUND,
          error: `Missing item(id: ${itemId}).`,
        },
        404,
      );
    }

    try {
      await this.itemsService.deleteItemByPassword(+itemId, deleteItemDTO.deletePassword);
    } catch (e) {
      if (e.message === 'Incorrect password') {
        throw new HttpException(
          {
            status: HttpStatus.FORBIDDEN,
            error: 'Incorrect password',
          },
          403,
        );
      }

      throw new HttpException(
        {
          status: HttpStatus.INTERNAL_SERVER_ERROR,
          error: 'Internal server error.',
        },
        500,
      );
    }
    return;
  }
}
```

実際にリクエストを投げてみます。まずは間違ったパスワードで削除リクエストを発行。

```terminal
>
http post http://localhost:3000/items/1/delete deletePassword="aaaa"
HTTP/1.1 403 Forbidden
Connection: keep-alive
Content-Length: 43
Content-Type: application/json; charset=utf-8
Date: Thu, 05 Dec 2019 18:58:13 GMT
ETag: W/"2b-OWVTwuDMlKLTkzKDbhZJRyNxniI"
X-Powered-By: Express

{
    "error": "Incorrect password",
    "status": 403
}
```

問題なさそうですね。そのまま正常な削除リクエストを発行してみます。

```terminal
>
http post http://localhost:3000/items/1/delete deletePassword="1234"
HTTP/1.1 201 Created
Connection: keep-alive
Content-Length: 0
Date: Thu, 05 Dec 2019 18:59:37 GMT
X-Powered-By: Express



```

ちゃんと消えてそうです。ちゃんと消えているということは、正しく次は 404 が返ってくるはずなので、最後にもう一度投げてみます。

```terminal
>
http post http://localhost:3000/items/1/delete deletePassword="1234"
HTTP/1.1 404 Not Found
Connection: keep-alive
Content-Length: 45
Content-Type: application/json; charset=utf-8
Date: Thu, 05 Dec 2019 18:59:55 GMT
ETag: W/"2d-dBP3gQggfxDbaRtjCpyMA+7ZBCM"
X-Powered-By: Express

{
    "error": "Missing item(id: 1).",
    "status": 404
}

```

無事実装できていそうです。

## おわりに

このように、Exception Filter を利用することで、簡潔かつ柔軟に異常系を取り回すことができます。また、本記事では紹介しませんが、 Exception Filter を自身でカスタムすることも可能となっております。

異常時にロギングなど、何かしらの処理を追加したい場合などは、公式ドキュメントのカスタマイズ例をご参照ください。

https://docs.nestjs.com/exception-filters#exception-filters-1

明日は @euxn23 より「NestJS を Production ready にする」です。