😺

NestJSを触りながら学ぶ(TodoAPI作成)

2020/12/04に公開
2

NestJSとは

Nest (NestJS) is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with and fully supports TypeScript (yet still enables developers to code in pure JavaScript) and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Reactive Programming).

Under the hood, Nest makes use of robust HTTP Server frameworks like Express (the default) and optionally can be configured to use Fastify as well!

Nest provides a level of abstraction above these common Node.js frameworks (Express/Fastify), but also exposes their APIs directly to the developer. This gives developers the freedom to use the myriad of third-party modules which are available for the underlying platform.

Node.jsのバックエンドフレームワークです。
デフォルトでExpress(Node.jsのWebアプリケーションフレームワーク)が内部で使用されていますので、Expressの技術を流用できます。
設定変更でFastify(軽量なNode.jsフレームワーク)を使用することも可能です。
またTypescriptで構成されているので、フロントエンドの方でもすんなり入っていけると思われます。

NestJSのアーキテクチャ

NestJSは下図のような構成例で開発することができます。


構成例

  • Module
    • 依存関係の管理を行う。
    • ルートモジュール(上図でいうAppModule)が開始点となる。
  • Controller
    • クライアントからのリクエストを処理し、応答を返す。
    • 上図の構成だと実処理はServiceが担っていることが多く、リソースを取得するエンドポイントだけ定義している。
  • Service
    • NestJSにおけるプロバイダーの一種。依存性の注入に利用される。
    • 上図の構成だとControllerに機能提供している。

環境

  • Node.js v12.19.1
  • PostgreSQL 12.5
  • macOS Catalina

インストール

以下のコマンドでNest CLIをインストールし、プロジェクトを作成します。

$ npm i -g @nestjs/cli
$ nest new TodoApp        // nest new [プロジェクト名]
⚡  We will scaffold your app in a few seconds..

CREATE todo-app/.eslintrc.js (630 bytes)
CREATE todo-app/.prettierrc (51 bytes)
CREATE todo-app/README.md (3339 bytes)
CREATE todo-app/nest-cli.json (64 bytes)
CREATE todo-app/package.json (1962 bytes)
CREATE todo-app/tsconfig.build.json (97 bytes)
CREATE todo-app/tsconfig.json (339 bytes)
CREATE todo-app/src/app.controller.spec.ts (617 bytes)
CREATE todo-app/src/app.controller.ts (274 bytes)
CREATE todo-app/src/app.module.ts (249 bytes)
CREATE todo-app/src/app.service.ts (142 bytes)
CREATE todo-app/src/main.ts (208 bytes)
CREATE todo-app/test/app.e2e-spec.ts (630 bytes)
CREATE todo-app/test/jest-e2e.json (183 bytes)

? Which package manager would you ❤️  to use? (Use arrow keys)
❯ npm
  yarn
///// npm か yarnか選びます(今回はyarnを選んでる) ////

▹▸▹▹▹ Installation in progress... ☕


🚀  Successfully created project todo-app
👉  Get started with the following commands:

$ cd todo-app
$ yarn run start


                          Thanks for installing Nest 🙏
                 Please consider donating to our open collective
                        to help us maintain this package.


               🍷  Donate: https://opencollective.com/nest

出来上がる構成は下記のようになります。

todo-app
    ├── .eslintrc.js          // ESLintの設定ファイル
    ├── .git                  // gitリポジトリのスケルトンディレクトリ
    ├── .gitignore            // git管理除外設定ファイル
    ├── .prettierrc           // Prettierの設定ファイル
    ├── README.md             // NestJSのREADME
    ├── dist                  // ビルド結果出力先
    ├── nest-cli.json         // プロジェクトを整理、構築、デプロイするために必要なメタデータが書いてあるファイル(Monorepo)
    ├── node_modules          // プロジェクトで利用する npm ライブラリの配置先
    ├── package.json          // npm の設定ファイル
    ├── src                   // 実装していくコードの配置先
    ├── test                  // E2Eテストコード配置先
    ├── tsconfig.build.json   // tsconfig.jsonの分割ファイル(デフォルトはトランスパイ対象外ディレクトリが書いてある)
    ├── tsconfig.json         // TypeScript を JavaScript にトランスパイルための設定情報ファイル
    └── yarn.lock             // yarnの構成管理ファイル(npmだとpackage-lock.jsonが生成されてます)

動作確認だけします。

$ cd todo-app
$ yarn run start
yarn run v1.22.5
$ nest start
[Nest] 132   - 11/20/2020, 1:52:37 PM   [NestFactory] Starting Nest application...
[Nest] 132   - 11/20/2020, 1:52:37 PM   [InstanceLoader] AppModule dependencies initialized +56ms
[Nest] 132   - 11/20/2020, 1:52:37 PM   [RoutesResolver] AppController {}: +39ms
[Nest] 132   - 11/20/2020, 1:52:37 PM   [RouterExplorer] Mapped {, GET} route +19ms
[Nest] 132   - 11/20/2020, 1:52:38 PM   [NestApplication] Nest application successfully started +7ms

Nest application successfullyが出力されたらブラウザでhttp://localhost:3000/を開きHello World!が表示されていたらOK。
Ctrl + Cで終了できます。

準備

データベース作成

作成方法はコマンド、クライアントアプリ(pgAdminなど)の使用、どちらでも構いません。

コマンド

postgres=# create database todoapp;
CREATE DATABASE

SQLクライアントアプリ

ファイル削除

srcディレクトリの直下は以下のファイルが配置されています。
今回必要ないものは削ってしまいます。

  • app.controller.spec.ts

    削除対象。AppControllerのユニットテスト用ファイル。

  • app.controller.ts

    削除対象。デフォルトコントローラ。

  • app.module.ts

    ルートモジュール。

  • app.service.ts

    削除対象。デフォルトプロバイダ(サービス)。

  • main.ts

    NestJSアプリケーションインスタンスを作成するエントリファイル。

main.tsapp.module.tsだけ残ります。
削除ついでにapp.module.tsも修正しておきます。

app.module.ts
import { Module } from '@nestjs/common';

@Module({
  imports: [],
})
export class AppModule {}
// AppController と AppService をファイル内から削除

必要パッケージインストール

npm なら npm install [パッケージ名] --save yarn なら yarn add [パッケージ名] で各種インストールします。

パッケージ 用途
@nestjs/typeorm TypeORM統合(NestJS専用パッケージ)
typeorm TypeORM使用
pg PostgreSQL使用
@nestjs/config 環境変数使用
class-validator バリデーション用(パイプ機能で使用)
class-transformer オブジェクト変換用(パイプ機能で使用)

Module作成

構成

今回は下図のようなシンプルな構成で作っていこうと思います。

CLI(Genarate Module)

CLIを使用してModuleを作成します。
nest g module [module名]で自動生成されます。

$ nest g module tasks
CREATE src/tasks/tasks.module.ts (82 bytes)
UPDATE src/app.module.ts (312 bytes)

中身は以下のように、ほとんど空のような状態です。

tasks/tasks.module.ts
import { Module } from '@nestjs/common';

@Module({})
export class TasksModule {}

Controller作成

CLI(Genarate Controller)

こちらもCLIを使用してControllerを作成します。
nest g controller [controller名]で生成されます。
同時にテスト用のファイルも自動生成されます。今回は使用しないので--no-specオプションを追加して生成しないようにします。

$ nest g controller tasks --no-spec
CREATE src/tasks/tasks.controller.ts (99 bytes)
UPDATE src/tasks/tasks.module.ts (170 bytes)

この時点でtasks.module.tsに自動でControllerが追記されています。

tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';    // 自動追加

@Module({
  controllers: [TasksController]  // 自動追加
})
export class TasksModule {}

ルーティング

各リクエストのルートマッピングは以下のように構成します。

URL HTTP Method Decorator Contents
/tasks GET getTasks @Get() 登録されているタスク取得
/tasks/[id] GET getTaskById @Get(/:id) [id]のタスク取得
/tasks POST createTask @Post() 新規タスク登録
/tasks/[id] DELETE deleteTask @Delete(/:id) 登録されている[id]のタスク削除
/tasks/[id] PATCH updateTaskStatus @Patch(/:id) 登録されている[id]のタスク更新

HTTPプロトコルの参考リンク

https://zenn.dev/amezousan/articles/2020-10-15-http-basic

コントローラ内のメソッドにデコレータを付与してあげることで、ハンドラとしてマッピングします。

Controller修正

上記の構成で作成します。

tasks/tasks.controller.ts
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';

@Controller('tasks')
export class TasksController {
    
    @Get()
    getTasks() {
        return "getTasks Success!"
    }

    @Get('/:id')
    getTaskById(
        @Param('id', ParseIntPipe) id: number) {
        return `getTaskById Success! Parameter [id:${id}]`
    }

    @Post()
    createTask(
        @Body('title') title: string,
        @Body('description') description: string) {
        return `createTask Success! Prameter [title:${title}, descritpion:${description}]`
    }

    @Delete('/:id')
    deleteTask(
        @Param('id', ParseIntPipe) id: number) {
        return `deleteTask Success! Prameter [id:${id}]`
    }

    @Patch('/:id')
    updateTask(
        @Param('id', ParseIntPipe) id: number,
        @Body('status') status: string ) {
        return `updateTask Success! Prameter [id:${id}, status:${status}]`
    }
}

@Param()@Body()はHTTPリクエストを取得するためのデコレータです。
基本はExpressのリクエストオブジェクトのプロパティに沿っています。(Express Application Request)

ParseIntPipeはパイプという機能の1つです。
コントローラのメソッドに値が引き渡される前に変換、もしくは検証を行います。
@Param('id', ParseIntPipe)ではidを数値型へと変換します。(変換できなかった場合は例外をスローします)

動作確認

早速確認しましょう。
yarn run start:devでファイル更新があるたびに自動的に再起動してくれるので、開発中はそちらでNestJSを起動するのがオススメです。
APIの確認は素直にツールを使用した方が手間がないです。
参考:

もちろんcurlでも可能。

$ curl -X GET http://localhost:3000/tasks
getTasks Success!
$ curl -X GET http://localhost:3000/tasks/1
getTaskById Success! Parameter [id:1]
$ curl -X POST http://localhost:3000/tasks -d 'title=TEST' -d 'description=NestJS'
createTask Success! Prameter [title:TEST, descritpion:NestJS]
$ curl -X PATCH http://localhost:3000/tasks/1 -d 'status=DONE'
updateTask Success! Prameter [id:1, status:DONE]
$ curl -X DELETE http://localhost:3000/tasks/1
deleteTask Success! Prameter [id:1]

// パイプで変換できない場合、いい感じに返してくれる(内容変更可能)
$ curl -X GET http://localhost:3000/tasks/aaa
{"statusCode":400,"message":"Validation failed (numeric string is expected)","error":"Bad Request"}

リクエストオブジェクトから値が取得できていることが分かると思います。

DTO作成

値を渡す際のバリデーションを有効するためにDTOを定義していきます。

tasks/dto/task-property.dto.ts
import { IsNotEmpty } from "class-validator";

export class TaskPropertyDto {
    @IsNotEmpty()
    title: string;

    @IsNotEmpty()
    description: string;
}

デコレータの@IsNotEmptyは値に"",null,undefinedを受け入れません。

Pipe作成

statusに対するパイプ機能を実装します。

tasks/pipe/task-status.pipe.ts
import { BadRequestException, PipeTransform } from "@nestjs/common";

export class TaskStatusPipe implements PipeTransform {
    readonly allowStatus = [
        'OPEN',
        'PROGRESS',
        'DONE'
    ];

    transform(value: any) {
        value = value.toUpperCase();

        if(!this.isStatusValid(value)) {
            throw new BadRequestException();
        }

        return value;
    }
    private isStatusValid(status: any) {
        const result = this.allowStatus.indexOf(status);
        return result !== -1;
    }
}

PipeTransformを継承することによって検証機能をもつパイプを自ら作成することができます。
上記はstatus'OPEN','PROGRESS','DONE'のいづれかでないとエラーを返しています。

Controller修正(DTO、Pipe追加)

tasks/tasks.controller.ts
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, UsePipes, ValidationPipe } from '@nestjs/common';
import { TaskPropertyDto } from './dto/task-property.dto';
import { TaskStatusPipe } from './pipe/task-status.pipe';

@Controller('tasks')
export class TasksController {
    
    @Get()
    getTasks() {
        return "getTasks Success!"
    }

    @Get('/:id')
    getTaskById(
        @Param('id', ParseIntPipe) id: number) {
        return `getTaskById Success! Parameter [id:${id}]`
    }

    @Post()
    @UsePipes(ValidationPipe)
    createTask(
        @Body() taskPropertyDto: TaskPropertyDto) {
        const { title, description } = taskPropertyDto
        return `createTask Success! Prameter [title:${title}, descritpion:${description}]`
    }

    @Delete('/:id')
    deleteTask(
        @Param('id', ParseIntPipe) id: number) {
        return `deleteTask Success! Prameter [id:${id}]`
    }

    @Patch('/:id')
    updateTask(
        @Param('id', ParseIntPipe) id: number,
        @Body('status',TaskStatusPipe) status: string ) {
        return `updateTask Success! Prameter [id:${id}, status:${status}]`
    }
}

@UsePipesを付与したメソッドはバリデーションパイプ(今回でいうとTaskPropertyDtoで定義した値の空検証)が有効になります。
updateTaskで作成したパイプを定義していますが、この場合ですとstatusの値にだけおよびます。

Service作成

CLI(Genarate Service)

CLIでServiceを作成します。
nest g service [service名]で生成されます。
今回はテストファイルも作ってみましょう。

$ nest g service tasks
CREATE src/tasks/tasks.service.spec.ts (453 bytes)
CREATE src/tasks/tasks.service.ts (89 bytes)
UPDATE src/tasks/tasks.module.ts (247 bytes)

tasks.module.tsに自動で追記されてます。

tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';    // 自動追加

@Module({
  controllers: [TasksController],
  providers: [TasksService]    // 自動追加
})
export class TasksModule {}

TypeORMの準備

Service実装の前に、DBとのやりとりのためにTypeORMの設定を行っておきます。

接続設定

DBとの接続を設定します。
app.module.tsに下記を追加します。

app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';      // 追加
import { TasksModule } from './tasks/tasks.module';

@Module({
  imports: [
    TasksModule,
    // importsに追加
    TypeOrmModule.forRoot({
      type: 'postgres',       // DBの種類
      port: 5432,             // 使用ポート
      database: 'todoapp',    // データベース名
      host: 'localhost',      // DBホスト名
      username: 'root',       // DBユーザ名
      password: 'root',       // DBパスワード
      synchronize: true,      // モデル同期(trueで同期)
      entities: [__dirname + '/**/*.entity.{js,ts}'],  // ロードするエンティティ
    })
  ],
})
export class AppModule {}

この状態でyarn run startをしてみましょう。

[Nest] 118   - 11/29/2020, 6:04:30 AM   [NestFactory] Starting Nest application...
[Nest] 118   - 11/29/2020, 6:04:31 AM   [InstanceLoader] TypeOrmModule dependencies initialized +933ms
[Nest] 118   - 11/29/2020, 6:04:31 AM   [InstanceLoader] AppModule dependencies initialized +4ms
[Nest] 118   - 11/29/2020, 6:04:31 AM   [InstanceLoader] TasksModule dependencies initialized +3ms
[Nest] 118   - 11/29/2020, 6:04:32 AM   [InstanceLoader] TypeOrmCoreModule dependencies initialized +1270ms
[Nest] 118   - 11/29/2020, 6:04:32 AM   [RoutesResolver] TasksController {/tasks}: +3ms
[Nest] 118   - 11/29/2020, 6:04:32 AM   [RouterExplorer] Mapped {/tasks, GET} route +3ms
[Nest] 118   - 11/29/2020, 6:04:32 AM   [RouterExplorer] Mapped {/tasks/:id, GET} route +4ms
[Nest] 118   - 11/29/2020, 6:04:32 AM   [RouterExplorer] Mapped {/tasks, POST} route +5ms
[Nest] 118   - 11/29/2020, 6:04:32 AM   [RouterExplorer] Mapped {/tasks/:id, DELETE} route +4ms
[Nest] 118   - 11/29/2020, 6:04:32 AM   [RouterExplorer] Mapped {/tasks/:id, PATCH} route +2ms
[Nest] 118   - 11/29/2020, 6:04:32 AM   [NestApplication] Nest application successfully started +9ms

エラーが出なければ接続成功です。

Entitiyの作成

以下のようなテーブル内容で作成しようと思います。

Task
id: number
title: string
description: string
status: string

新規にtask.entity.tsを作成します。
TypeORMについての説明は省略させて頂きます。
参考: TypeORM(github)

tasks/task.entity.ts
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class Task extends BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    title: string;

    @Column()
    description: string;

    @Column()
    status: string;
}

yarn run startでNestJSを起動してDBの中を見てみます。
(下記はコマンドで確認してますが、確認できればなんでもよいです。)

todoapp=# \d
            List of relations
 Schema |    Name     |   Type   | Owner
--------+-------------+----------+-------
 public | task        | table    | root
 public | task_id_seq | sequence | root
(2 rows)

todoapp=# \d task
                                    Table "public.task"
   Column    |       Type        | Collation | Nullable |             Default

-------------+-------------------+-----------+----------+----------------------------------
 id          | integer           |           | not null | nextval('task_id_seq'::regclass)
 title       | character varying |           | not null |
 description | character varying |           | not null |
 status      | character varying |           | not null |
Indexes:
    "PK_fb213f79ee45060ba925ecd576e" PRIMARY KEY, btree (id)

ちゃんと同期がとれてますね。

環境毎での設定変更

envファイル

開発時点ではこれでいいのですが、本番環境では同期するとエライ目にあうので、環境変数を読み込むように変更します。
プロジェクトのルートディレクトリ直下に.envディレクトリを新規に作成し、各環境毎の構成ファイルを格納します。

.env/default.env
API_PORT=3000
DB_PORT=5432
DB_NAME=todoapp
DB_HOSTNAME=localhost
DB_USERNAME=root
DB_PASSWORD=root
.env/development.env
DB_SYNC=true
.env/production.env
DB_SYNC=false

環境毎にdevlopment.envproduction.envを切り替えて使います(default.envは他envファイルで上書き可能な共通定義を書いてます)

TypeORMのConfigService

この定義を読み込むTypeORMのConfigServiceを作成します。
srcディレクトリにconfigディレクトリを作成し格納します。

config/typeorm-config.service.ts
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from "@nestjs/typeorm";

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
    createTypeOrmOptions(): TypeOrmModuleOptions {
        const configService = new ConfigService();
        return {
            type: 'postgres',
            host: configService.get<string>('DB_HOSTNAME'),
            port: configService.get<number>('DB_PORT'),
            username: configService.get<string>('DB_USERNAME'),
            password: configService.get<string>('DB_PASSWORD'),
            database: configService.get<string>('DB_NAME'),
            entities: [__dirname + '/../**/*.entity.{js,ts}'],
            synchronize: this.strToBoolean(configService.get<string>('DB_SYNC', 'false'))
        };
    }
    // get<boolean>が上手く変換してくれないため泣く泣く対応
    private strToBoolean(boolStr:string):boolean {
        switch(boolStr.toLowerCase().trim())
        {
            case "true":
            case "yes":
            case "1":
            return true;
            case "false":
            case "no":
            case "0":
            case null:
            return false;
            default:
            return boolStr as unknown as boolean
        }
    }
}

ConfigServiceのgetメソッドで環境変数を取得できます。
さきほどapp.module.tsに直接記述していたオプション達をここにまとめておきます。

AppModule修正

app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeOrmConfigService } from './config/typeorm-config.service';
import { TasksModule } from './tasks/tasks.module';

@Module({
  imports: [
    /** env読み込み
    *   環境変数NODE_ENVの値によって読み込むファイルを切り替える。
    *   default.envは後続で呼ばれる。同じ変数がある場合は先に定義されているものが優先される。
    */ 
    ConfigModule.forRoot({
      envFilePath: [`.env/${process.env.NODE_ENV}.env`,'.env/default.env'],
      isGlobal: true,
    }),
    // TypeORMの設定を非同期取得に変更
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useClass: TypeOrmConfigService,
    }),
    TasksModule,
  ],
})
export class AppModule {}

これで環境ごとに接続情報を書き換える手間を省けます。

動作確認

分かりやすくコンソールに表示されるようmain.tsを書き換えましょう(ついでlistenポートもenvを参照するようにしちゃいましょう)

main.ts
import { ConfigService } from '@nestjs/config';       // 追加
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  
  const app = await NestFactory.create(AppModule);
  const configService = new ConfigService();          // 追加
  
  const sync = configService.get('DB_SYNC');          // 追加
  console.log(`TypeORM synchronize is [ ${sync} ]`);  // 追加

  const port = configService.get('API_PORT'); // 追加
  await app.listen(port);                     // 3000をportに変更
}
bootstrap();

NODE_ENVを設定して起動してみます。

// dev
$ NODE_ENV=development yarn run start
yarn run v1.22.5
$ nest start
[Nest] 195   - 11/29/2020, 3:06:23 PM   [NestFactory] Starting Nest application...
~~~ 略 ~~~~
[Nest] 195   - 11/29/2020, 3:06:24 PM   [InstanceLoader] TasksModule dependencies initialized +1ms
[Nest] 195   - 11/29/2020, 3:06:25 PM   [InstanceLoader] TypeOrmCoreModule dependencies initialized +560ms
TypeORM synchronize is [ true ]
~~~ 略 ~~~~

// prod
$ NODE_ENV=production yarn run start
yarn run v1.22.5
$ nest start
[Nest] 195   - 11/29/2020, 3:06:23 PM   [NestFactory] Starting Nest application...
~~~ 略 ~~~~
TypeORM synchronize is [ false ]
~~~ 略 ~~~~

// ランタイム環境に既に環境変数が存在する場合、そちらが優先される
$ NODE_ENV=development DB_SYNC=false yarn run start
yarn run v1.22.5
$ nest start
[Nest] 195   - 11/29/2020, 3:06:23 PM   [NestFactory] Starting Nest application...
~~~ 略 ~~~~
TypeORM synchronize is [ false ]
~~~ 略 ~~~~

(ここまで書いてなんだが、node-config使った方が綺麗にまとまりそうですね・・・)

Serviceの修正

tasks/tasks.service.ts
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TaskPropertyDto } from './dto/task-property.dto';
import { Task } from './task.entity';

@Injectable()
export class TasksService {
    constructor(
        @InjectRepository(Task)
        private taskRepository: Repository<Task>,
    ) { }

    async getTasks(): Promise<Task[]> {
        return this.taskRepository.find();
    }

    async getTaskById(id: number): Promise<Task> {
        const found = await this.taskRepository.findOne(id);

        if (!found) {
            throw new NotFoundException();
        }

        return found;
    }

    async createTask(
        taskPropertyDto: TaskPropertyDto
        ): Promise<Task> {
        const { title, description } = taskPropertyDto;
        const task = new Task();
        task.title = title;
        task.description = description;
        task.status = 'OPEN';

        try {
            await this.taskRepository.save(task);
        } catch (error) {
            throw new InternalServerErrorException();
        }

        return task;
    }

    async deleteTask(id: number): Promise<void> {
        const result = await this.taskRepository.delete(id);

        if (result.affected === 0) {
            throw new NotFoundException();
        }
    }

    async updateTask(
        id: number,
        status: string): Promise<Task> {
        const task = await this.getTaskById(id);
        task.status = status;
        await this.taskRepository.save(task);
        return task;
    }
}

Controllerから受け取った値で実処理するよう構成しています。

あとはControllerのファイルを修正すれば完成なのですが、Service作成時にテストファイルを作成したので、テストを行っていきましょう。

テスト作成

テストはJestがデフォルトになっています。
NestJSはテストツールに制限があるわけでないので、お好みでよいようです。
今回はデフォルトのJestでいきます。

tasks/tasks.service.spec.ts
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Task } from './task.entity';
import { TasksService } from './tasks.service';

const mockRepository = () => ({
  find: jest.fn(),
  findOne: jest.fn(),
  save: jest.fn(),
  delete: jest.fn(),
});

describe('TasksService', () => {
  let tasksService: TasksService;
  let taskRepository;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        TasksService,
        { provide: getRepositoryToken(Task), useFactory: mockRepository }
      ]
    }).compile();

    tasksService = await module.get<TasksService>(TasksService);
    taskRepository = await module.get(getRepositoryToken(Task));
  });

  describe('getTasks', () => {
    it('get all tasks', async () => {
      taskRepository.find.mockResolvedValue('mockTask');
      expect(taskRepository.find).not.toHaveBeenCalled();

      const result = await tasksService.getTasks()
      expect(taskRepository.find).toHaveBeenCalled();
      expect(result).toEqual('mockTask');
    });
  });

  describe('getTaskById', () => {
    it('find success', async () => {
      const mockTask = { title: 'mockTitle', description: 'mockDesc' };
      taskRepository.findOne.mockResolvedValue(mockTask);
      expect(taskRepository.findOne).not.toHaveBeenCalled();

      const mockId: number = 1;
      const result = await tasksService.getTaskById(mockId);
      expect(taskRepository.findOne).toHaveBeenCalled();
      expect(result).toEqual(mockTask);
    });

    it('task is not found', async () => {
      const mockId: number = 1;
      taskRepository.findOne.mockResolvedValue(null);
      expect(tasksService.getTaskById(mockId)).rejects.toThrow(NotFoundException);
    });
  });

  describe('createTask', () => {
    it('insert task', async () => {
      const mockTask = { title: 'mockTitle', description: 'mockDesc' };
      taskRepository.save.mockResolvedValue(mockTask);
      expect(taskRepository.save).not.toHaveBeenCalled();
		
      const result = await tasksService.createTask(mockTask);
      expect(taskRepository.save).toHaveBeenCalled();
      expect(result).toEqual({
        title: mockTask.title,
        description: mockTask.description,
        status: 'OPEN'
      });
    });
  });

  describe('deleteTask', () => {
    it('delete task', async () => {
      taskRepository.delete.mockResolvedValue({ affected: 1 });
      expect(taskRepository.delete).not.toHaveBeenCalled();
      const mockId: number = 1;
      await tasksService.deleteTask(mockId);
      expect(taskRepository.delete).toHaveBeenCalledWith(mockId);
    });

    it('delete error', async () => {
      taskRepository.delete.mockResolvedValue({ affected: 0 });

      const mockId: number = 1;
      expect(tasksService.deleteTask(mockId)).rejects.toThrow(NotFoundException);
    });
  });

  describe('updateTask', () => {
    it('update status', async () => {
      const mockStatus = 'DONE';
      tasksService.getTaskById = jest.fn().mockResolvedValue({
        status: 'OPEN'
      });
      expect(tasksService.getTaskById).not.toHaveBeenCalled();

      const mockId: number = 1;
      const result = await tasksService.updateTask(mockId, mockStatus);
      expect(tasksService.getTaskById).toHaveBeenCalled();
      expect(taskRepository.save).toHaveBeenCalled();
      expect(result.status).toEqual(mockStatus);
    });
  });
});

beforeEachで呼ばれているTest.createTestingModuleはモックやオーバーライドなど、クラスインスタンスの管理を容易にしてくれるフックの機能があります。
今回はDBのアクセスはモックにするので、Repositoryクラスのfindsaveはオーバライドしています。

テスト実行

yarn run testで実行しましょう。(yarn run test:watchでファイルが更新する度テストをしてくれることも出来ます)

Watch Usage: Press w to show more.
 PASS  src/tasks/tasks.service.spec.ts (21.055 s)
  TasksService
    getTasks
      ✓ get all tasks (9 ms)
    getTaskById
      ✓ find success (5 ms)
      ✓ task is not found (19 ms)
    createTask
      ✓ insert task (6 ms)
    deleteTask
      ✓ delete task (5 ms)
      ✓ delete error (5 ms)
    updateTask
      ✓ update status (5 ms)

Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total
Snapshots:   0 total
Time:        21.135 s, estimated 22 s

なんだが良く分からないけどエラーがないのでヨシ!(テストムズカシイ・・・)

Module & Controller修正

ControllerはServiceに値を渡すよう修正しましょう。

tasks/task.controller.ts
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { TaskPropertyDto } from './dto/task-property.dto';
import { TaskStatusPipe } from './pipe/task-status.pipe';
import { Task } from './task.entity';
import { TasksService } from './tasks.service';

@Controller('tasks')
export class TasksController {
    constructor(
        private tasksService: TasksService
    ) {}
    
    @Get()
    getTasks(): Promise<Task[]> {
        return this.tasksService.getTasks();
    }

    @Get('/:id')
    getTaskById(
        @Param('id', ParseIntPipe) id: number): Promise<Task> {
        return this.tasksService.getTaskById(id);
    }

    @Post()
    @UsePipes(ValidationPipe)
    createTask(
        @Body() taskPropertyDto: TaskPropertyDto
        ): Promise<Task> {
        return this.tasksService.createTask(taskPropertyDto);
    }

    @Delete('/:id')
    deleteTask(
        @Param('id', ParseIntPipe) id: number): Promise<void> {
        return this.tasksService.deleteTask(id);
    }

    @Patch('/:id')
    updateTask(
        @Param('id', ParseIntPipe) id: number,
        @Body('status',TaskStatusPipe) status: string ): Promise<Task> {
        return this.tasksService.updateTask(id, status);
    }
}

Moduleはリポジトリをimportしておきます。

tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';   // 追加
import { Task } from './task.entity';              // 追加
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';

@Module({
  imports: [
    TypeOrmModule.forFeature([Task])   // 追加
  ],
  controllers: [TasksController],
  providers: [TasksService]
})
export class TasksModule {}

確認

yarn run startで起動して確認します。

// get (登録なし)
$ curl -X GET http://localhost:3000/tasks
[]
// create (1)
$ curl -X POST http://localhost:3000/tasks -d 'title=Test' -d 'description=testDesc'
{"title":"Test","description":"testDesc","status":"OPEN","id":1}
// get (登録あり)
$ curl -X GET http://localhost:3000/tasks
[{"id":1,"title":"Test","description":"testDesc","status":"OPEN"}]
// create (2)
$ curl -X POST http://localhost:3000/tasks -d 'title=Nest' -d 'description=nestjs'
{"title":"Nest","description":"nestjs","status":"OPEN","id":2}
// get (id指定)
$ curl -X GET http://localhost:3000/tasks/2
{"id":2,"title":"Nest","description":"nestjs","status":"OPEN"}
// get (複数登録あり)
$ curl -X GET http://localhost:3000/tasks
[{"id":1,"title":"Test","description":"testDesc","status":"OPEN"},{"id":2,"title":"Nest","description":"nestjs","status":"OPEN"}]
// update
$ curl -X PATCH http://localhost:3000/tasks/2 -d 'status=DONE'
{"id":2,"title":"Nest","description":"nestjs","status":"DONE"}
// get (update確認)
$ curl -X GET http://localhost:3000/tasks/2
{"id":2,"title":"Nest","description":"nestjs","status":"DONE"}
// delete
$ curl -X DELETE http://localhost:3000/tasks/2
// get (delete確認)
$ curl -X GET http://localhost:3000/tasks
[{"id":1,"title":"Test","description":"testDesc","status":"OPEN"}]
// 不正な値の確認
$ curl -X POST http://localhost:3000/tasks -d 'title=Nest' -d 'description='
{"statusCode":400,"message":["description should not be empty"],"error":"Bad Request"}
$ curl -X PATCH http://localhost:3000/tasks/2 -d 'status=aaa'
{"statusCode":400,"message":"Bad Request"}

まとめ

NestJSを使って必要最低限なREST方式で実装してみました。
ここに紹介しているだけじゃなくNestJSには、もっと便利な機能がありますし拡張性が高いです。
(書いている途中で記事じゃなく本の方が見やすかったと後悔・・・)
唯一日本語ドキュメントが少ないという意見を良く見かけますが、今では日本語の記事も多くなってきて調べやすい環境になっているのかなと思います。

Discussion

ganyariyaganyariya

参考になる記事ありがとうございました!(全体像をとてもわかり易く理解することができました!)
全体像が分かった状態で,さらに公式ドキュメントで勉強しようと思います><

以下,気づいた点です><

  • ルーティング@Post@Podtになっているようです
  • Entityの作成で, task.entity.ts だと思うのですが,ソースコードの名前が tasks/tasks.entity.ts で (s)がついていました
  • Test作成createTask の引数の渡し方が違いそうです.
      const result = await tasksService.createTask(mockTask.title, mockTask.description);
      const result = await tasksService.createTask(mockTask);