28
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

Nest.js+MySQLで動くCRUD+認証APIサーバをDocker+VSCode+Swaggerで構築してみる

はじめに

HAL Advent Calendar初参加のHAL大阪Web4年の小澤です。
今年一番世話になった言語,フレームワーク,ライブラリについて感謝の念を込めて(文量的に)カロリー高めのハンズオン記事を書いてみました。
対戦よろしくお願いします。

■ タイトルやたら長いけど何が目的?

  • 学校の授業では学べない(であろう)TypeScriptを広めたい
    • TypeScriptについて学ぶ為の(体感で)前工程に掛かる時間が一番少なかったNest.jsを広めたい
      • Swaggerの便利さとそれを自動生成できる機能の快適さを広めたい
      • Nest.jsの認証機構の実装についての資料を残したい
  • VSCode + TypeScript(ECMAScript) + Docker構成のDXの良さを広めたい (DX: Developer Experience)

※ 当記事はMacユーザーを対象としています。 (投稿者がMacユーザーな為)

■ 目次

■今回作るもの

アカウント登録制Todoアプリ用APIサーバ

(完成品: https://github.com/tk-ozawa/nest_todos_sample)

■使用技術等

  • Docker (Docker Compose)
  • TypeScript
    • Nest.js
    • TypeORM
      • TypeORM Seeding
  • Swagger
  • MySQL
  • phpMyAdmin
  • VSCode
  • ESLint
  • Prettier

(学生目線で)当記事に読んでもらいたい人のレベル感としては、

  • APIが何かがわかる
  • Laravelとかその辺のサーバサイドフレームワークを使ったことがある
    • 一般的なORM(Active RecordやEloquent)の操作感が分かる
  • JavaScriptの基本知識、ESのPromise, async/awaitとか配列, オブジェクトの取り回し辺りが分かる

を目安にしています。
(Dockerの触り方が分かるならよりスムーズかと思いますが、当記事をベースに知識を付けても良いかもしれない。)

■ハンズオン

ディレクトリ構成

今回作っていくリポジトリのディレクトリ構成は以下のようになります。

※ 最終的な形のものなのでNest.jsインストール時とは異なります

todos/
    ┣━ backend/
    ┃    ┣━ dist/
    ┃    ┣━ node_modules/
    ┃    ┣━ src/
    ┃    ┃    ┣━ auth/
    ┃    ┃    ┣━ entities/
    ┃    ┃    ┣━ migrations/
    ┃    ┃    ┃    ┣━ factories/
    ┃    ┃    ┃    ┗━ seeders/
    ┃    ┃    ┣━ todos/
    ┃    ┃    ┣━ app.controller.spec.ts
    ┃    ┃    ┣━ app.controller.ts
    ┃    ┃    ┣━ app.module.ts
    ┃    ┃    ┣━ app.service.ts
    ┃    ┃    ┗━ main.ts
    ┃    ┣━ test/
    ┃    ┣━ .eslintrc
    ┃    ┣━ .gitignore
    ┃    ┣━ .prettierrc
    ┃    ┣━ Dockerfile
    ┃    ┣━ nest-cli.json
    ┃    ┣━ ormconfig.js
    ┃    ┣━ ormconfig.local.js
    ┃    ┣━ package-lock.json
    ┃    ┣━ package.json
    ┃    ┣━ README.md
    ┃    ┣━ tsconfig.build.json
    ┃    ┗━ tsconfig.json
    ┣━ .dockerignore
    ┣━ .env
    ┣━ docker-compose.yml
    ┗━ Makefile

0. 事前準備

Docker for Macの導入

Homebrew & nodebrewの導入

XCode & Command Line Tools for Xcodeのインストール

Homebrewを動かす為に必要。(MacOSの場合)
AppStoreからXCodeをインストール。その後、ターミナルにて以下を実行

$ xcode-select --install
Homebrewのインストール

nodebrewのインストール, Node.js & npmのバージョン指定

https://qiita.com/7110/items/efe0be1be11bed1db143 等を参考

Node.jsの利用バージョンはv14.5.0を指定 (多分これ以外でも動くけどこれより古いと怪しい)

リポジトリ用ディレクトリ作成

ログインユーザーのドキュメントルートから本アプリのリポジトリ用ディレクトリを作っていきます

/ $ cd ~
~ $ mkdir todos && code todos/

以降は todos/ を開いたVSCodeの画面から作業を行います

1. Nest.jsインストール

Nest.jsをインストールする為のツールである nestjs/cli をホスト(Mac)にインストール

~/todos $ npm i -g @nestjs/cli
~/todos $ nest -v
7.5.1

Nest.js アプリのテンプレートを生成します。(ここではnpmを選択)

~/todos $ nest new backend
? Which package manager would you ❤️  to use? (Use arrow keys)
❯ npm 
  yarn
⚡  We will scaffold your app in a few seconds..

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

? Which package manager would you ❤️  to use? npm
✔ Installation in progress... ☕

🚀  Successfully created project backend
👉  Get started with the following commands:

$ cd backend
$ npm run start


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


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

終わり次第、backendディレクトリに移動しておきます

~/todos $ cd backend

Dockerfile 作成

Nest.jsを動かす為のNode.jsコンテナを作っていきます。
(Nest.js公式Imageがあるみたいだけど更新が止まってるので自作します。)

~/todos/backend $ touch Dockerfile
todos/backend/Dockerfile
FROM node:12.13-alpine

WORKDIR /usr/src/app

COPY package*.json .

RUN apk add --no-cache make gcc g++ python && \
  npm install && \
  npm rebuild bcrypt --build-from-source && \
  apk del make gcc g++ python

COPY . .

RUN npm run build

.dockerignore の作成

ホスト(Mac)上にあるトランスパイル後のjsファイルやnode_modules等は、
docker-compose up(docker-compose build)実行時の待ち時間が長くなる原因になるので、
Dockerイメージのビルド時は無視するように設定します。

(Dockerfileと同じ階層で置きたいけどdocker-compose環境では無視される模様
参考: https://teratail.com/questions/214664)

~/todos $ touch .dockerignore
todos/.dockerignore
backend/dist
backend/node_modules

ESLint + Prettierによるコード保存時自動フォーマットを有効にする

VSCodeの設定ファイルを追加します

todos/.vscode/settings.json
{
  "eslint.workingDirectories": ["backend"],
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

また、Nest.jsのテンプレートに足りていないESLint用プラグインを追加します。

~/todos/backend $ npm i -D eslint-plugin-prettier

.eslintrcの置き換え

何故かv6.7.2のnestjsだとESLint + Prettierによるコード保存時自動フォーマットが効かない模様...

https://tkzawa.netlify.app/201023/ を参考。(自分が書いた記事)

以下の内容で置き換える。 (ファイル名は.eslint.json -> .eslintrcに変更)

todos/backend/.eslintrc
{
  "root": true,
  "env": {
    "node": true,
    "es6": true,
    "jest": true
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "project": "./tsconfig.json",
    "sourceType": "module"
  },
  "plugins": [
    "@typescript-eslint"
  ],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint"
  ],
  "rules": {
    "@typescript-eslint/interface-name-prefix": "off",
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/explicit-module-boundary-types": "off",
    "@typescript-eslint/no-explicit-any": "off"
  }
}

2. コンテナ作成

docker-compose.yml 作成

DBコンテナは基本的にDockerhubにあるMySQLイメージをそのまま使います。
また、ブラウザで使える簡易的なDBクライアントとしてphpMyAdminを含んでいます

~/todos $ touch docker-compose.yml
todos/docker-compose.yml
version: '3'
services:
  backend:
    container_name: todos_backend
    build:
      context: ./backend
    volumes:
      - ./backend:/usr/src/app
      - /usr/src/app/node_modules
    ports:
      - ${BACKEND_PORT}:${BACKEND_INNER_PORT}
    command: sh -c "npm install && npm run start:dev"
    environment:
      MYSQL_HOST: todos_database
      MYSQL_USERNAME: ${MYSQL_USERNAME}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_DATABASE_NAME: ${MYSQL_DATABASE}
      MYSQL_PORT: ${MYSQL_PORT}
      BACKEND_INNER_PORT: ${BACKEND_INNER_PORT}
      JWT_SECRET: ${JWT_SECRET}

  database:
    container_name: todos_database
    image: mysql:5.7
    volumes:
      - mysql-db:/var/lib/mysql
    command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
    ports:
      - ${MYSQL_PORT}:3306
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}

  phpmyadmin:
    container_name: todos_pma
    image: phpmyadmin/phpmyadmin
    ports:
      - ${PMA_PORT}:80
    environment:
      PMA_HOST: todos_database
      PMA_USER: ${MYSQL_USERNAME}
      PMA_PASSWORD: ${MYSQL_PASSWORD}
    depends_on:
      - database

volumes:
  mysql-db:
    driver: local

.env 作成

docker-compose.yml 用の環境変数を別途 .env で記述します。
(メリットはdocker-compose.yml上にあるポート等の文字列を纏められる & docker-toolbox環境の人と共存できたり等)

~/todos $ touch .env

JWT_SECRET には任意の半角英数の文字列を設定してください

todos/.env
# backend
BACKEND_PORT=3001
BACKEND_INNER_PORT=3000
JWT_SECRET=

# database
MYSQL_PORT=3306
MYSQL_USERNAME=root
MYSQL_PASSWORD=root
MYSQL_DATABASE=api

# phpMyAdmin(databse client)
PMA_PORT=8888
PMA_USER=root
PMA_PASSWORD=root

コンテナ作成

上述の docker-compose.yml 及び Dockerfile を基に、Dockerのイメージ及びコンテナを作成します。

~/todos $ docker-compose up

また、npm経由でライブラリを追加した場合は、ターミナルの別タブにて

# 各コンテナ停止 -> 各イメージ更新 -> 各コンテナ起動
~/todos $ docker-compose down && docker-compose build && docker-compose up

でDockerイメージ及びコンテナを更新しましょう。(.dockerignoreによってnode_modulesをホスト<->コンテナ間(volume内)で共有していない為)

各コンテナの動作確認

URL 確認
Nest.js http://localhost:3001 JSONでHello World!が取得できているか
MySQL & phpMyAdmin http://localhost:8888 phpMyAdminにアクセスできる & 各DBが表示されているか

3. TypeORM導入

TypeORM利用時に必要となる、接続DB等を記述した設定ファイルを用意します。

ここでは、実際のアプリ内実行時に読み込む ormconfig.js と、
マイグレーションファイルの生成等の為にCLI実行する際に使う ormconfig.local.js を分けて作成します。
(CLI実行だと dist/ が更新されない為)

~/todos/backend $ touch ormconfig.js
~/todos/backend $ touch ormconfig.local.js
todos/backend/ormconfig.js
module.exports = {
  type: 'mysql',
  host: process.env.MYSQL_HOST,
  port: process.env.MYSQL_PORT,
  username: process.env.MYSQL_USERNAME,
  password: process.env.MYSQL_PASSWORD,
  database: process.env.MYSQL_DATABASE_NAME,
  entities: ['dist/**/*.entity.{js,ts}'],
  migrations: ['dist/migrations/*.{js,ts}'],
  seeds: ['dist/migrations/seeders/*.seed.{js,ts}'],
  factories: ['dist/migrations/factories/*.factory.{js,ts}'],
  cli: {
    migrationsDir: 'dist/migrations',
    entitiesDir: 'dist/entities',
    seedersDir: 'dist/migrations/seeds',
    factoriesDir: 'dist/migrations/factories',
  },
  synchronize: false,
};
todos/backend/ormconfig.local.js
module.exports = {
  type: 'mysql',
  host: process.env.MYSQL_HOST,
  port: process.env.MYSQL_PORT,
  username: process.env.MYSQL_USERNAME,
  password: process.env.MYSQL_PASSWORD,
  database: process.env.MYSQL_DATABASE_NAME,
  entities: ['src/**/*.entity.{js,ts}'],
  migrations: ['src/migrations/*.{js,ts}'],
  seeds: ['src/migrations/seeders/*.seed.{js,ts}'],
  factories: ['src/migrations/factories/*.factory.{js,ts}'],
  cli: {
    migrationsDir: 'src/migrations',
    entitiesDir: 'src/entities',
    seedersDir: 'src/migrations/seeds',
    factoriesDir: 'src/migrations/factories',
  },
  synchronize: false,
};

TypeORMインストール

TypeScript環境でTypeORM利用に必要となるライブラリをインストールします。

~/todos/backend $ npm install mysql @nestjs/typeorm typeorm typeorm-seeding faker
~/todos/backend $ npm install -D @types/faker @types/faker

また、TypeORM(+ TypeORM Seeding)をCLI上で利用する為のコマンドも追加しておきます。

todos/backend/package.json
{
  ...
  "scripts": {
    ...
    "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config ormconfig.local.js",
    "typeorm:migration:generate": "npm run typeorm migration:generate -- --name",
    "typeorm:migration:create": "npm run typeorm migration:create -- --name",
    "typeorm:migration:run": "npm run typeorm migration:run",
    "typeorm:migration:revert": "npm run typeorm migration:revert",
    "typeorm:seed": "ts-node ./node_modules/typeorm-seeding/dist/cli.js --configName ormconfig.local.js",
    "typeorm:seed:run": "npm run typeorm:seed seed",
    "typeorm:schema:drop": "ts-node ./node_modules/typeorm/cli.js schema:drop"
  },
  ...
}

これらのnpm scriptsはコンテナから実行される前提となっている為、そのままではホスト(Mac)上からは実行できません。
ですがそれでは取り回しが悪いので、ホストからこれらのnpm scriptsを発火できるMakeコマンドを用意しておきましょう。

~/todos $ touch Makefile
todos/Makefile
# tips. $(name)があるコマンドは引数としてname=hogehogeを与える
migration-create:
    docker container exec -it todos_backend sh -c "npm run typeorm:migration:create $(name)"
migration-generate:
    docker container exec -it todos_backend sh -c "npm run typeorm:migration:generate $(name)"
migration-run:
    docker container exec -it todos_backend sh -c "npm run typeorm:migration:run"
migration-revert:
    docker container exec -it todos_backend sh -c "npm run typeorm:migration:revert"
seeding-run:
    docker container exec -it todos_backend sh -c "npm run typeorm:seed:run"
schema-drop:
    docker container exec -it todos_backend sh -c "npm run typeorm:schema:drop"
schema-sync:
    docker container exec -it todos_backend sh -c "npm run typeorm:schema:drop && npm run typeorm:migration:run && npm run typeorm:seed:run"

4. Entity作成 + マイグレーション & シーディング

TypeORMのMigration機能は、Laravelの標準ORMであるEloquentのように
MigrationファイルからEntityクラスを作成」していくのではなく、
EntityクラスからMigrationファイルを作成」していく設計となってます。(何故かは分からず。。詳しい人ご助言ください)

したがって、Entityが無ければそれに対応するテーブル作成用のMigrationファイルを作ってMigrateしようとしてもエラーが出ます。

Entityクラス作成

今回は認証機能付きTodoアプリ用APIを作るので、必要なEntityは以下のようになります。(超適当)
とその前に、ユーザーのパスワードは平文で保存するわけにはいかないのでハッシュ用ライブラリ(bcrypt.js)を導入しましょう。

bcrypt.jsのインストール
~/todos/backend $ npm install bcrypt nan node-gyp node-pre-gyp
~/todos/backend $ npm install -D @types/bcrypt
user.entity.ts の作成

それっぽい仕様:
- ユーザーはemail & passwordでログイン
- usersテーブルとtodosテーブルは1:Nで対応する

~/todos/backend $ mkdir src/entities 
~/todos/backend $ touch src/entities/user.entity.ts

まずは親テーブルであるUserEntityから作成します。

Auto importの使い方

Auto importが効くコード補完の使い方 (ex. @Entity)
@Entity@E 辺りで補完の候補を見て、
補完候補文字列の横にあるアイコンが橙, 青, 紫のものを中からいずれかを選択(Enter)。

todos/backend/src/entities/user.entity.ts
// import部分は自動挿入される為、基本的に手入力はしない
import {
  Entity,
  BaseEntity
} from 'typeorm';

// ここから書き始める
@Entity({
  name: 'users',
})
export class User extends BaseEntity {}

次に、usersテーブルのカラムとなる各プロパティを追加します。
(hoge!! についての参考: https://qiita.com/zigenin/items/364264a6cf635b962542)

todos/backend/src/entities/user.entity.ts
...
export class User extends BaseEntity {
  id!: number;

  email!: string;

  password!: string;

  createdAt!: Date;

  updatedAt!: Date;
}

各カラム(プロパティ)のデータ型や桁数等を設定したり、idや, Timestamp(createdAt & updatedAt)等のカラムにはデータベースの機能(自動更新系)を設定したい為、
それ用のデコレータを付与していきます。

todos/backend/src/entities/user.entity.ts
...
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({
    unique: true,
    nullable: false,
    length: 255,
  })
  email!: string;

  @Column({
    nullable: false,
    length: 255,
  })
  password!: string;

  @CreateDateColumn({
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP(6)',
  })
  createdAt!: Date;

  @UpdateDateColumn({
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP(6)',
    onUpdate: 'CURRENT_TIMESTAMP(6)',
  })
  updatedAt!: Date;
}

ユーザーのパスワードは、
insert時: 暗号化されて作成
select時: 暗号化されたまま取得
されるべきなので、Entity内でその挙動を実装します。

todos/backend/src/entities/user.entity.ts
...
import * as bcrypt from 'bcrypt'; // 補完が効かないので手入力

export class User extends BaseEntity {
  ...
  @Column({
    ...
    transformer: {
      to: (raw: string) => bcrypt.hashSync(raw, 5),
      from: (hashed: string) => hashed,
    },
  })
  password!: string;
  ...
}
todo.entity.ts の作成
~/todos/backend $ touch src/entities/todo.entity.ts

user.entity.ts の作成である程度作成の流れが掴めたかと思うので、TodoEntityの作成はさらっと流します。(リレーション設定は後述)

todos/backend/src/entities/todo.entity.ts
...
export enum CATEGORY {
  SCHOOL = 'school',
  OFFICE = 'office',
  GENERAL = 'general',
}

@Entity({
  name: 'todos',
})
export class Todo extends BaseEntity {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({
    nullable: false,
    type: 'text',
  })
  title!: string;

  @Column({
    type: 'enum',
    enum: CATEGORY,
  })
  category!: CATEGORY;

  @Column({
    nullable: false,
  })
  userId!: number;

  @CreateDateColumn({
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP(6)',
  })
  createdAt!: Date;

  @UpdateDateColumn({
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP(6)',
    onUpdate: 'CURRENT_TIMESTAMP(6)',
  })
  updatedAt!: Date;
}

todo.entity.tsuser.entity.ts ができたので、それらをリレーション設定で繋ぎます。

todos/backend/src/entities/user.entity.ts
import {
  ...
  OneToMany
} from 'typeorm';
...
import { Todo } from './entities/todo.entity';

export class User extends BaseEntity {
  ...
  @OneToMany(
    () => Todo,
    todo => todo.user,
  )
  todos: Todo[];
}
todos/backend/src/entities/todo.entity.ts
import {
  ...
  ManyToOne,
  JoinColumn
} from 'typeorm';
...
import { User } from './entities/user.entity';

export class Todo extends BaseEntity {
  ...
  @ManyToOne(
    () => User,
    user => user.todos,
  )
  @JoinColumn({
    name: 'userId',
  })
  user: User;
}

これでEntity系は完成です。

Migrationファイル作成

上記の全Entityが作成できた時点でMigrationファイルを作成していきます。
(事前に用意したMakefileに記述したコマンドを利用します)

~/todos $ make migration-generate name=CreateBaseTables

このコマンドによって、Entityの内容を基に以下のようなファイルが生成されます。(ランダムに付与されている文字列は異なります)

todos/backend/src/migrations/1605592391715-CreateBaseTables.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateBaseTables1605592391715 implements MigrationInterface {
  name = 'CreateBaseTables1605592391715';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      'CREATE TABLE `users` (`id` int NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, `password` varchar(255) NOT NULL, `createdAt` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedAt` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_97672ac88f789774dd47f7c8be` (`email`), PRIMARY KEY (`id`)) ENGINE=InnoDB',
    );
    await queryRunner.query(
      "CREATE TABLE `todos` (`id` int NOT NULL AUTO_INCREMENT, `title` text NOT NULL, `category` enum ('school', 'office', 'general') NOT NULL, `userId` int NOT NULL, `createdAt` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedAt` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB",
    );
    await queryRunner.query(
      'ALTER TABLE `todos` ADD CONSTRAINT `FK_4583be7753873b4ead956f040e3` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION',
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      'ALTER TABLE `todos` DROP FOREIGN KEY `FK_4583be7753873b4ead956f040e3`',
    );
    await queryRunner.query('DROP TABLE `todos`');
    await queryRunner.query(
      'DROP INDEX `IDX_97672ac88f789774dd47f7c8be` ON `users`',
    );
    await queryRunner.query('DROP TABLE `users`');
  }
}

(RailsやLaravelに搭載されているようなORMの場合、テーブル作成用のマイグレーションファイルは1ファイル:1テーブルで管理するんですがTypeORMはそれらを一括(1ファイル)でやっちゃうのが謎…)

テーブル作成(Migration)

作成されたMigrationファイルを基に、データベース上にテーブルを作成します。

~/todos $ make migration-run
docker container exec -it todos_backend sh -c "npm run typeorm:migration:run"

> backend@0.0.1 typeorm:migration:run /usr/src/app
> npm run typeorm migration:run


> backend@0.0.1 typeorm /usr/src/app
> ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config ormconfig.local.js "migration:run"

query: SELECT * FROM `INFORMATION_SCHEMA`.`COLUMNS` WHERE `TABLE_SCHEMA` = 'api' AND `TABLE_NAME` = 'migrations'
query: CREATE TABLE `api`.`migrations` (`id` int NOT NULL AUTO_INCREMENT, `timestamp` bigint NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB
query: SELECT * FROM `api`.`migrations` `migrations`  ORDER BY `id` DESC
0 migrations are already loaded in the database.
1 migrations were found in the source code.
1 migrations are new migrations that needs to be executed.
query: START TRANSACTION
query: CREATE TABLE `users` (`id` int NOT NULL AUTO_INCREMENT, `email` varchar(255) NOT NULL, `password` varchar(255) NOT NULL, `createdAt` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedAt` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX `IDX_97672ac88f789774dd47f7c8be` (`email`), PRIMARY KEY (`id`)) ENGINE=InnoDB
query: CREATE TABLE `todos` (`id` int NOT NULL AUTO_INCREMENT, `title` text NOT NULL, `category` enum ('school', 'office', 'general') NOT NULL, `userId` int NOT NULL, `createdAt` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedAt` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB
query: ALTER TABLE `todos` ADD CONSTRAINT `FK_4583be7753873b4ead956f040e3` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
query: INSERT INTO `api`.`migrations`(`timestamp`, `name`) VALUES (?, ?) -- PARAMETERS: [1607327470893,"CreateBaseTables1607327470893"]
Migration CreateBaseTables1607327470893 has been executed successfully.
query: COMMIT

一番下辺りに Migration CreateBaseTables<Unixタイムスタンプ> has been executed successfully. と出ていればマイグレーション成功です。

作成したデータベースの確認方法は各コンテナの動作確認に記載しています。

Seeder & Factory作成

開発環境用のダミーデータを作る各ファイルをTypeORM上で作ります。

  • Seeder
    • データをデータベースに登録するファイル
  • Factory
    • Seeder内でどんなデータを生成するかを定義するファイル

Seeding時、SeederがFactoryを呼び出す形になるのでまずFactoryを作成していきます。

Factory

まず最初にディレクトリを作成します。

~/todos/backend $ mkdir src/entities/migrations/factories
user.factory.ts の作成
~/todos/backend $ touch src/migrations/factories/user.factory.ts

ユーザーのパスワードは固定値(hogehoge)、
メールアドレスはダミーの値(faker利用)で登録します。
パスワードは、先述の設定によってinsert時にbcryptで暗号化されます。

todos/backend/src/migrations/factories/user.factory.ts
import { define } from 'typeorm-seeding';
import { User } from '../../entities/user.entity';
import * as Faker from 'faker/locale/ja';

define(User, (faker: typeof Faker) => {
  const user = new User();
  user.email = faker.internet.email();
  user.password = 'hogehoge';

  return user;
});
todo.factory.ts の作成
~/todos/backend $ touch src/migrations/factories/todo.factory.ts

TodoはDBに存在し得るユーザーのIDをuserIdに含む必要があります。
その為、SeederからuserIdを受け取る形(define()の第2引数の, クロージャー内の第2引数Context)を取ります。
また、enum型のCATEGORYから任意のデータを取得する為、enumを配列として扱うようにしています。

todos/backend/src/migrations/factories/todo.factory.ts
import { define } from 'typeorm-seeding';
import { Todo, CATEGORY } from '../../entities/todo.entity';
import * as Faker from 'faker/locale/ja';

interface Context {
  id: number;
  userMax: number;
}

define(Todo, (faker: typeof Faker, context: Context) => {
  const { id, userMax } = context;
  const userId = Math.floor(Math.random() * userMax) + 1;

  const categories: CATEGORY[] = Object.keys(CATEGORY).map(k => CATEGORY[k]);
  const category = categories[id % categories.length];

  const todo = new Todo();
  todo.title = `${faker.lorem.word()}${id}`;
  todo.category = category;
  todo.userId = userId;

  return todo;
});
Seeder

↑で書いた各Factoryに対応するSeederファイルを作成します。
Factoryと同様に、最初にディレクトリを作成します。(できればライブラリのCLIアプリ側で生成してほしい……)

~/todos/backend $ mkdir src/entities/migrations/seeders
10-user.seed.ts の作成

Factoryとは違い、Seederにはファイルの実行順序が存在しています。(外部参照制約)
ここでは、実行順序を付ける為にファイル名の先頭に10-等の番号を割り当てています。

~/todos/backend $ touch src/migrations/seeders/10-user.seed.ts

今回は1実行につき3件(3人分)作成するようにします。

todos/backend/src/migrations/seeders/10-user.seed.ts
import { Factory, Seeder } from 'typeorm-seeding';
import { User } from '../../entities/user.entity';

export default class CreateUsers implements Seeder {
  public async run(factory: Factory) {
    await factory(User)().createMany(3);
  }
}
20-todo.seed.ts の作成

todosはusersに対して従属する為、usersのデータ投入よりも後に実行したいのでファイル名の先頭に20-と打っています。

~/todos/backend $ touch src/migrations/seeders/20-todo.seed.ts

実行1回につき10件のデータを入れたい為、for文を使用しています。
run()内は非同期で処理される為、run()内におけるforの処理そのものをawaitさせる為にPromiseを返す即時実行される無名関数でラップしています。

todos/backend/src/migrations/seeders/20-todo.seed.ts
import { getRepository } from 'typeorm';
import { Factory, Seeder } from 'typeorm-seeding';
import { Todo } from '../../entities/todo.entity';
import { User } from '../../entities/user.entity';

export default class CreateTodos implements Seeder {
  public async run(factory: Factory) {
    const todoRepository = getRepository(Todo);
    const todoMax = await todoRepository.count();

    const userRepository = getRepository(User);
    const userMax = await userRepository.count();

    await (async () => {
      for (let insertId = todoMax + 1; insertId <= todoMax + 10; insertId++) {
        await factory(Todo)({
          id: insertId,
          userMax,
        }).create();
      }
      return Promise.resolve();
    })();
  }
}

ダミーデータ投入(Seeding)

make seeding-run で以下のようなメッセージが出ていればSeeing成功です。(phpMyAdmin上での確認推奨)

~/todos $ make seeding-run
docker container exec -it todos_backend sh -c "npm run typeorm:seed:run"

> backend@0.0.1 typeorm:seed:run /usr/src/app
> npm run typeorm:seed seed


> backend@0.0.1 typeorm:seed /usr/src/app
> ts-node ./node_modules/typeorm-seeding/dist/cli.js --configName ormconfig.local.js "seed"

🌱  TypeORM Seeding v1.6.1
✔ ORM Config loaded
✔ Factories are imported
✔ Seeders are imported
✔ Database connected
✔ Seeder CreateUsers executed
✔ Seeder CreateTodos executed
👍  Finished Seeding

5. Swagger有効化

今回は、Nest.jsで作成するAPIの仕様書をSwaggerを使って作成していきます。
Swaggerの内容は手書きするのでなく、Nest.js内のコードから自動生成させます。

Swagger拡張のインストール

~/todos/backend $ npm i @nestjs/swagger swagger-ui-express

main.tsの設定

Nest.js内でSwagger拡張の機能を有効にする為、サーバの起点ファイルとなるmain.tsの内容を変更します。

todos/backend/src/main.ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // APIのURIをすべて/apiから始まるようにする
  app.setGlobalPrefix('api');

  // CORS対応
  app.enableCors();

  // Swagger拡張の有効化
  const options = new DocumentBuilder()
    .setTitle('API description')
    .setVersion('1.0')
    .addServer('/')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, options);
  SwaggerModule.setup('swagger', app, document);

  // PORT番号を初期値の3000からdocker-compose.ymlで記述していた環境変数に変更
  await app.listen(process.env.BACKEND_INNER_PORT);
}
bootstrap();

Swaggerはlocalhost:3001/swaggerから確認できます。

各EntityにSwagger用の設定付与(@ApiProperty())

各Entity(User, Todo)の各カラムをSwaggerに認識させる為、
テーブルに反映される(対応するカラムが存在する)すべてのプロパティの上部に@ApiProperty()を付与します。

// 付ける例: テーブルにカラムが存在する
@Column({
  nullable: false,
})
@ApiProperty()
userId!: number;

// 付けない例: テーブルにカラムが存在しない
@ManyToOne(
  () => User,
  user => user.todos,
)
@JoinColumn({
  name: 'userId',
})
user: User;

ここまででAPI開発の土台となる部分は完成です。
ここからは、API用のクラス及びメソッドを作成していきます。

Nest.jsは、以下のような設計になっています。

  • Module: コンポーネント間の依存関係の管理(DI)
  • Controller: ルーティングとRequest, Response及びSwagger上の情報の管理
  • Service: ビジネスロジックの管理
  • Repository: データ操作ロジックの管理
  • DTO: RequestPayloadのバリデーション及び型定義

まずは、ユーザー用APIを作成する準備として、上記の各レイヤーに対応するクラスを作成します。

module類作成

Nest.jsのCLIを使い、Module, Controller, Serviceを自動生成します。

~/todos/backend $ nest g mo Users
CREATE src/users/users.module.ts (82 bytes)
UPDATE src/app.module.ts (312 bytes)
~/todos/backend $ nest g co Users
CREATE src/users/users.controller.spec.ts (485 bytes)
CREATE src/users/users.controller.ts (99 bytes)
UPDATE src/users/users.module.ts (170 bytes)
~/todos/backend $ nest g s Users
CREATE src/users/users.service.spec.ts (453 bytes)
CREATE src/users/users.service.ts (89 bytes)
UPDATE src/users/users.module.ts (247 bytes)

6.1. [API] ユーザー登録機能作成

ユーザー登録の為のAPIを作成していきます。
まずはuser.controller.tsの中に1つのメソッドを作成します。
ついでとして、Service層を読み込んでおきます。(後で進めます)

todos/backend/src/users/users.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';

@ApiTags('users')
@Controller('users')
export class UsersController {
  constructor(private usersService: UsersService) {}

  @Post()
  signUp(@Body() hoge) {}
}

このままでは、signUp()のRequestデータ(hoge)の中にあるプロパティは、
どんな名前なのか、どんなデータ型なのかがわかりません。

それらを解決する為に、必要となるRequestデータを定義した独自のクラスを作成 & バリデーションチェック機能を提供するValidationPipeを使います。
「必要となるRequestデータを定義した独自のクラス」を、Nest.jsではDTOと呼びます。(DTOという名の抽象クラスが内部で用意されているわけでは無い)

ValidationPipeを有効にするには、型変換を提供するclass-transformerと入力チェックを
提供するclass-validatorというライブラリが必要となりますのでインストールしておきます。

~/todos/backend $ npm i class-transformer class-validator

DTO作成

ユーザー登録時に必要となるパラメータのみを記述したSignUpUserDTOクラスを作成します。

~/todos/backend $ mkdir src/users/dto
~/todos/backend $ touch src/users/dto/sign-up-user.dto.ts

ファイル名をアッパーキャメルで記したクラスを作成します。

todos/backend/src/users/dto/sign-up-user.dto.ts
export class SignUpUserDto {
}

user.entity.tsを基に、入力値であるカラム(プロパティ)を記述します。

todos/backend/src/users/dto/sign-up-user.dto.ts
export class SignUpUserDto {
  email: string;
  password: string;
}

以下2つの条件によるバリデーションをclass-validatorで実現する為、各プロパティに設定用のデコレータを付与します。

  • email: 入力必須で、メールアドレス形式の文字列である
  • password: 入力必須で、6~25桁の文字列である
todos/backend/src/users/dto/sign-up-user.dto.ts
import {
  IsEmail,
  IsNotEmpty,
  IsString,
  MaxLength,
  MinLength,
} from 'class-validator';

export class SignUpUserDto {
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @IsString()
  @MinLength(6)
  @MaxLength(25)
  @IsNotEmpty()
  password: string;
}

Swaggerでプロパティとして認識させる & 入力値の初期値等を設定する為、
各プロパティに@ApiPropertyを付与します。

todos/backend/src/users/dto/sign-up-user.dto.ts
...
import { ApiProperty } from '@nestjs/swagger';

export class SignUpUserDto {
  @ApiProperty({
    example: 'test1@gmail.com',
    type: String,
  })
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @ApiProperty({
    example: 'hogehoge',
    type: String,
    minLength: 6,
    maxLength: 25,
  })
  @IsString()
  @MinLength(6)
  @MaxLength(25)
  @IsNotEmpty()
  password: string;
}

これでサインアップ用のDTOは完成です。本題のAPI用メソッドの作成に戻ります。
サインアップ用APIのRequestデータをラッピングする為に作成したSignUpUserDtoクラスを、signUp()の引数に設定します。

todos/backend/src/users/users.controller.ts
...
import { SignUpUserDto } from './dto/sign-up-user.dto';

@ApiTags('users')
@Controller('users')
export class UsersController {
  ...

  @Post()
  signUp(@Body() signUpUserDto: SignUpUserDto) {}
}

バリデーションチェック機能を有効にする為、先述のValidationPipeを使用します。
一旦、ダミーレスポンスとして入力値をそのままreturnします。

todos/backend/src/users/users.controller.ts
...
import { Body, Controller, Post, ValidationPipe } from '@nestjs/common';
import { SignUpUserDto } from './dto/sign-up-user.dto';

@ApiTags('users')
@Controller('users')
export class UsersController {
  ...

  @Post()
  signUp(@Body(ValidationPipe) signUpUserDto: SignUpUserDto) {
    return signUpUserDto;
  }
}

Swagger/api/usersにて動作確認を行うと、バリデーション機能がしっかりと効いているのが確認できるかと思います。

ダミーAPIが完成したので、これよりDB操作を含んだAPIを作っていきます。

まず、TypeORMによるDB接続を有効にする為の設定を付与します。

todos/backend/src/app.module.ts
...

@Module({
  ...
  imports: [UsersModule, TypeOrmModule.forRoot()],
})
export class AppModule {}

今回は、DB操作を担うRepository層をカスタマイズして利用したい為、
Service層等を触る前にRepositoryクラスを作成します。(クラスの内部は空のままで大丈夫です)

~/todos/backend $ mkdir src/entities/repositories
~/todos/backend $ touch src/entities/repositories/user.repository.ts
todos/backend/src/entities/repositories/user.repository.ts
import { EntityRepository, Repository } from 'typeorm';
import { User } from '../user.entity';

@EntityRepository(User)
export class UserRepository extends Repository<User> {}

UserRepositoryクラスはそのままではusersディレクトリ内のController, Service等から呼び出せません。(DI機構の為)
Controller, Service等から呼び出す為に、コンポーネントの依存管理を管理しているModuleクラスであるusers.modules.tsに本クラスを読み込ませます。

todos/backend/src/users/users.module.ts
...
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserRepository } from '../entities/repositories/user.repository';

@Module({
  imports: [TypeOrmModule.forFeature([UserRepository])],
  ...
})
export class UsersModule {}

DB操作を行う下準備ができたので、サインアップ用APIにおけるDB操作をRepository層に記述します。

/todos/backend/src/entities/repositories/user.repository.ts
...
export class UserRepository extends Repository<User> {
  async createUser({ email, password }: SignUpUserDto): Promise<void> {
    const user = new User();
    user.email = email;
    user.password = password;
    await user.save();
  }
}

DBのunique制約によるエラー(409エラー)やDB接続エラー(500エラー)が起きる可能性を考慮して、
Nest.jsが用意している各Exceptionクラスを用いてバリデーションチェック(それに伴うエラーレスポンス)を行うようにします。

/todos/backend/src/entities/repositories/user.repository.ts
...
export class UserRepository extends Repository<User> {
  async createUser({ email, password }: SignUpUserDto): Promise<void> {
    const user = new User();
    user.email = email;
    user.password = password;
    try {
      await user.save();
    } catch (err) {
      if (err.code === 'ER_DUP_ENTRY') {
        throw new ConflictException(
          'メールアドレスが登録済みです',
        );
      }
      throw new InternalServerErrorException();
    }
  }
}

ユーザー登録用のDB操作メソッドが完成したので、ロジック部分を担うService層からそのメソッドを呼び出します。
まずは、上記で作成したUserRepositoryクラスをUsersServiceクラスに注入します。

todos/backend/src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserRepository } from '../entities/repositories/user.repository';

@Injectable()
export class UsersService {
  // ここから追加
  constructor(
    @InjectRepository(UserRepository) private userRepository: UserRepository,
  ) {}
}

UserRepositoryのcreateUser()は引数にDTOを持つので、Controller層から受け取るDTOをそのまま渡します。

todos/backend/src/users/users.service.ts
...
import { SignUpUserDto } from './dto/sign-up-user.dto';

@Injectable()
export class UsersService {
  ...

  async createUser(signUpUserDto: SignUpUserDto): Promise<void> {
    await this.userRepository.createUser(signUpUserDto);
  }
}

現状、ユーザー登録API用のController層のメソッドはダミーレスポンスを返すものになっているので、
その中身をUsersServiceのcreateUser()を呼び出す形に修正します。

todos/backend/src/users/users.controller.ts
...

@ApiTags('users')
@Controller('users')
export class UsersController {
  constructor(private usersService: UsersService) {}

  @Post()
  async signUp(
    @Body(ValidationPipe) signUpUserDto: SignUpUserDto,
  ): Promise<void> {
    await this.usersService.createUser(signUpUserDto);
  }
}

SwaggerphpMyAdminで、ユーザー登録ができていることを確認しましょう。

Swagger上でPOST /api/usersの情報を確認してみると、現状、成功時(201)のレスポンスに関する空の内容しかなく、エラー時のレスポンスに関する情報が載ってないことがわかります。
なのでここからは、エンドポイントが返す可能性のあるレスポンスをSwaggerから確認できるようにしていきます。

POST /api/usersに設定されているレスポンスは以下の4種です。これらをSwagger拡張を使って記述します。

  • ユーザー登録成功による201レスポンス
  • DTOでのバリデーションエラーによる404エラー
  • 登録済のメールアドレスとの重複による409エラー
  • ユーザー登録時にDB接続失敗による500エラー
todos/backend/src/users/users.controller.ts
...
import {
  ApiBadRequestResponse,
  ApiConflictResponse,
  ApiCreatedResponse,
  ApiInternalServerErrorResponse,
} from '@nestjs/swagger';

@ApiTags('users')
@Controller('users')
export class UsersController {
  constructor(private usersService: UsersService) {}

  @Post()
  @ApiCreatedResponse({
    description: 'ユーザー登録完了',
  })
  @ApiBadRequestResponse({
    description: '入力値のフォーマットエラー',
  })
  @ApiConflictResponse({
    description: 'メールアドレスの重複エラー',
  })
  @ApiInternalServerErrorResponse({
    description: 'DBサーバ接続エラー',
  })
  async signUp(
    @Body(ValidationPipe) signUpUserDto: SignUpUserDto,
  ): Promise<void> {
    await this.usersService.createUser(signUpUserDto);
  }
}

再度Swaggerを確認すると、POST /api/usersのレスポンスの種類が増えていることが確認できます。

6.2. [API] ユーザーログイン機能

ユーザー登録APIが完成したので、ユーザーログインAPIも作成していきます。
ユーザー登録APIと同様に、ユーザーログインAPIもユーザーから入力された値を受け取る為、DTOクラスを用意する必要があります。
DTOの作成手順についてはSignUpUserDtoの項にて説明済みの為、作成の意図と作成されるコード以外の説明は省きます。

DTO作成

~/todos/backend $ touch src/users/dto/sign-in-user.dto.ts

ユーザーログイン時は、ユーザー登録時で必要だった入力値の桁数をチェックする必要はありません。
その為、SignInUserDtoの内容は、SignUpUserDtoから桁数チェック用のデコレータを省いたものを作成します。

todos/backend/src/users/dto/sign-in-user.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';

export class SignInUserDto {
  @ApiProperty({
    example: 'test1@gmail.com',
    type: String,
  })
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @ApiProperty({
    example: 'hogehoge',
    type: String,
    minLength: 6,
    maxLength: 25,
  })
  @IsString()
  @IsNotEmpty()
  password: string;
}

今回はアカウント認証技術にJson Web Token(以下、JWT)、認証ミドルウェアにPassportを使います。
まずは、それらに関連するライブラリをインストールします。

~/todos/backend $ npm install passport passport-jwt @nestjs/passport @nestjs/jwt
~/todos/backend $ npm install -D @types/passport-jwt

インストール後、JWTを生成する為のコンポーネントを、ログイン機能を担うUsersServiceとそれを管理するUsersModuleに注入します。
(expiresInは署名の有効期限を示します。(1h : 1時間))

todos/backend/src/users/users.module.ts
...
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: '1h' },
    }),
    TypeOrmModule.forFeature([UserRepository]),
  ],
  ...
})
export class UsersModule {}
todos/backend/src/users/users.service.ts
...
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(UserRepository) private userRepository: UserRepository,
    private readonly jwtSecret: JwtService,
  ) {}

  ...
}

コンポーネント注入が終わったので、ユーザーログインAPIで必要となる大まかなロジックをUsersServiceに記述します。(signIn())
(今回、JWTにはemailを含ませます。)

todos/backend/src/users/users.service.ts
...

@Injectable()
export class UsersService {
  ...

  async signIn(signInUserDto: SignInUserDto): Promise<string> {
    // (dtoをRepository層でパスワードチェック等ゴニョゴニョして吐き出される予定の)認証の鍵となるデータ (今回はemail)
    const email = 'hoge';

    const payload = {
      email,
    };
    return await this.jwtSecret.signAsync(payload);
  }
}

UserRepositoryに、SignInUserDtoの値からDB内のユーザーを検索 & パスワードチェックを行う処理(validatePassword())を記述します。

todos/backend/src/entities/repositories/user.repository.ts
...
import { SignInUserDto } from '../../users/dto/sign-in-user.dto';
import * as bcrypt from 'bcrypt';
import {
  ...
  UnauthorizedException,
} from '@nestjs/common';

@EntityRepository(User)
export class UserRepository extends Repository<User> {
  ...

  async validatePassword({ email, password }: SignInUserDto) {
    const user = await this.findOne({ email });
    if (user && (await bcrypt.compare(password, user.password))) {
      return user.email;
    }
    throw new UnauthorizedException('メールアドレスまたはパスワードが違います');
  }
}

ユーザーログインAPI用のDB操作処理が完成したので、先程作りかけていたUsersServiceのsignIn()からvalidatePassword()を呼び出すようにします。

todos/backend/src/users/users.service.ts
...

@Injectable()
export class UsersService {
  ...

  async signIn(signInUserDto: SignInUserDto): Promise<string> {
    const email = await this.userRepository.validatePassword(signInUserDto); // 変更

    const payload = {
      email,
    };
    return await this.jwtSecret.signAsync(payload);
  }
}

Service層まで完成したので、ControllerにユーザーログインAPI(POST /api/users/sign_in)を作成します。
作成する際、Post通信であるという認識のまま成功時のレスポンスを返してしまうとステータスコードが正しくないもの(そのままだと201)を返してしまう為、
@HTTPCodeというデコレータを使って成功時に200を返すようにしています。

ついでに、ユーザー登録のエンドポイントもPOST /api/usersからPOST /api/users/sign_upに変更しておきます。

todos/backend/src/users/users.controller.ts
import {
  Body,
  Controller,
  HttpCode,
  Post,
  ValidationPipe,
} from '@nestjs/common';
...

@ApiTags('users')
@Controller('users')
export class UsersController {
  @Post('sign_up') // 変更
  ...
  async signUp(
    @Body(ValidationPipe) signUpUserDto: SignUpUserDto,
  ): Promise<void> {
    await this.usersService.createUser(signUpUserDto);
  }

  @Post('sign_in')
  @HttpCode(200)
  @ApiOkResponse({
    type: String,
    description: 'ユーザーログイン完了',
  })
  @ApiUnauthorizedResponse({
    description:
      'メールアドレスまたはパスワードが異なることによるログインエラー',
  })
  async signIn(
    @Body(ValidationPipe) signInUserDto: SignInUserDto,
  ): Promise<string> {
    return await this.usersService.signIn(signInUserDto);
  }
}

ここまででユーザーの登録/ログインの機構が完成したのですが、
まだトークンの認証ロジック(JWT内のemailが存在するものであるかどうか)が存在していない為、認証機能自体はまだ完成していません。

ここから作成する認証周りの機能としては以下のものになります。

  1. Passportを使って、トークンの内容がDB(users)に存在しているかをチェックする
    1. ↑をパスした際に取得できるユーザー情報をカスタムパラメータ化して取り回しやすくする
  2. エンドポイント別(今回は実装しない)、Controller別のデコレータを使った認証ガード設定の付与

トークンの認証機構作成

Passportとpassport-jwtを使ってトークンの内容をチェックする為に、Strategyというクラスを作成します。

~/todos/backend $ mkdir src/users/strategy
~/todos/backend $ touch src/users/strategy/jwt.strategy.ts
todos/backend/src/users/strategy/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserRepository } from '../../entities/repositories/user.repository';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(UserRepository)
    private userRepository: UserRepository,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET,
    });
  }

  async validate(payload) {
    // ここでpayloadとDB内のデータを照合する
  }
}

validate()の引数であるpayloadはUsersServiceのsignIn()で使用されているものと型が同じものなので、それらを自作のinterfaceで関連付けておきます。

~/todos/backend $ mkdir src/users/interface
~/todos/backend $ touch src/users/interface/jwt-payload.interface.ts
todos/backend/src/users/interface/jwt-payload.interface.ts
export interface JwtPayload {
  email: string;
}
todos/backend/src/users/strategy/jwt.strategy.ts
...
import { JwtPayload } from '../interface/jwt-payload.interface';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  ...

  async validate({ email }: JwtPayload) {}
}
todos/backend/src/users/users.service.ts
...
import { JwtPayload } from './interface/jwt-payload.interface';

@Injectable()
export class UsersService {
  ...

  async signIn(signInUserDto: SignInUserDto): Promise<string> {
    const email = await this.userRepository.validatePassword(signInUserDto);

    const payload: JwtPayload = {
      email,
    };
    return await this.jwtSecret.signAsync(payload);
  }
}

型指定ができたので、JwtStrategyのvalidate()の中身を作ります。(返り値の型も指定します)
emailを基にユーザーを検索し、ヒット時にリレーション先の情報(todos)も含めて取得します。

todos/backend/src/users/strategy/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
...

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  ...

  async validate({ email }: JwtPayload): Promise<User> {
    const user = await this.userRepository.findOne({
      relations: ['todos'],
      where: [{ email }],
    });
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}

JWT認証の為のクラス群をUsersModuleに注入します。

todos/backend/src/users/users.module.ts
...
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './strategy/jwt.strategy';

@Module({
  imports: [
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: '1h' },
    }),
    TypeOrmModule.forFeature([UserRepository]),
  ],
  controllers: [UsersController],
  providers: [UsersService, JwtStrategy],
})
export class UsersModule {}

さらに、認証ガード下にあるController内でログインユーザーの情報を取り回し易くする為、カスタムパラメータを作成します。
(@GetUserというデコレータ下にある引数 = ログインユーザーの情報となります。)

~/todos/backend $ touch src/users/get-user.decorator.ts
todos/backend/src/users/get-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '../entities/user.entity';

export const GetUser = createParamDecorator(
  (_: unknown, ctx: ExecutionContext): User => {
    const req = ctx.switchToHttp().getRequest();
    return req.user;
  },
);

これで、他のAPI(Controller)への認証ガード付与以外のアカウント認証機構が完了しました。
/users以下はこれで完成となります。

7.1. [API] タスク登録機能作成

ユーザー機能が完成したので、タスク関連の機能を作成します。

module類作成

usersの項と同様に、Nest.js CLIを使ってmodule類を生成していきます。

~/todos/backend $ nest g mo Todos
CREATE src/todos/todos.module.ts (82 bytes)
UPDATE src/app.module.ts (451 bytes)
~/todos/backend $ nest g co Todos
CREATE src/todos/todos.controller.spec.ts (485 bytes)
CREATE src/todos/todos.controller.ts (99 bytes)
UPDATE src/todos/todos.module.ts (170 bytes)
~/todos/backend $ nest g s Todos
CREATE src/todos/todos.service.spec.ts (453 bytes)
CREATE src/todos/todos.service.ts (89 bytes)
UPDATE src/todos/todos.module.ts (247 bytes)

タスク関連のクラスを生成できたので、TodoRepositoryクラスを作成しTodosModuleTodosServiceに注入します。

~/todos/backend $ touch src/entities/repositories/todo.repository.ts
todos/backend/src/entities/repositories/todo.repository.ts
import { EntityRepository, Repository } from 'typeorm';
import { Todo } from '../todo.entity';

@EntityRepository(Todo)
export class TodoRepository extends Repository<Todo> {}
todos/backend/src/todos/todos.module.ts
...
import { TypeOrmModule } from '@nestjs/typeorm';
import { TodoRepository } from '../entities/repositories/todo.repository';

@Module({
  imports: [TypeOrmModule.forFeature([TodoRepository])],
  ...
})
export class TodosModule {}
todos/backend/src/todos/todos.service.ts
...
import { InjectRepository } from '@nestjs/typeorm';
import { TodoRepository } from '../entities/repositories/todo.repository';

@Injectable()
export class TodosService {
  constructor(
    @InjectRepository(TodoRepository) private todoRepository: TodoRepository,
  ) {}
}

更にTodosServiceTodosControllerに注入します。
TodosController内の全エンドポイントは認証ガードを実施すべきですので、
それ用のデコレータをクラス全体に付与して認証ガードを有効にします。

todos/backend/src/todos/todos.controller.ts
import { Controller, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { TodosService } from './todos.service';

@ApiTags('todos')
@Controller('todos')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
export class TodosController {
  constructor(private todosService: TodosService) {}
}

DTO作成

タスク登録時に必要となるパラメータのみを記述したCreateTodoDtoクラスを作成します。
入力される値とその入力条件は以下の通りです。

  • title: 入力必須で、文字列である
  • category: 入力必須で、CATEGORY(enum)に含まれる文字列である
~/todos/backend $ mkdir src/todos/dto
~/todos/backend $ touch src/todos/dto/create-todo.dto.ts
todos/backend/src/todos/dto/create-todo.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { CATEGORY } from '../../entities/todo.entity';

export class CreateTodoDto {
  @ApiProperty({
    example: 'AdventCalenderの記事書く',
    type: String,
  })
  @IsString()
  @IsNotEmpty()
  title: string;

  @ApiProperty({
    example: CATEGORY.GENERAL,
    type: 'enum',
  })
  @IsEnum(CATEGORY)
  @IsNotEmpty()
  category: CATEGORY;
}

DTOが完成したので、User登録APIと同様に
Repository層(具体的なDB操作)の作成 -> Service層(ロジック)の作成 -> Controller層(エンドポイントとの接続)をそれぞれ行っていきます。

todos/backend/src/entities/repositories/todo.repository.ts
...
import { CreateTodoDto } from '../../todos/dto/create-todo.dto';
import { User } from '../user.entity';

@EntityRepository(Todo)
export class TodoRepository extends Repository<Todo> {
  async createTodo(
    { title, category }: CreateTodoDto,
    user: User,
  ): Promise<Todo> {
    const todo = new Todo();
    todo.title = title;
    todo.category = category;
    todo.userId = user.id;
    await todo.save();

    delete todo.user;
    return todo;
  }
}
todos/backend/src/todos/todos.service.ts
...
import { CreateTodoDto } from './dto/create-todo.dto';
import { User } from '../entities/user.entity';
import { Todo } from '../entities/todo.entity';

@Injectable()
export class TodosService {
  ...

  async createTodo(createTodoDto: CreateTodoDto, user: User): Promise<Todo> {
    return await this.todoRepository.createTodo(createTodoDto, user);
  }
}
todos/backend/src/todos/todos.controller.ts
import {
  ...
  Body,
  ValidationPipe,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiCreatedResponse, ApiTags } from '@nestjs/swagger';
import { TodosService } from './todos.service';
import { GetUser } from '../users/get-user.decorator';
import { User } from '../entities/user.entity';
import { CreateTodoDto } from './dto/create-todo.dto';
import { Todo } from 'src/entities/todo.entity';

...
export class TodosController {
  ...

  @Post()
  @ApiCreatedResponse({
    description: 'タスク作成完了',
    type: Todo,
  })
  async createTodo(
    @Body(ValidationPipe) createTodoDto: CreateTodoDto,
    @GetUser() user: User,
  ): Promise<Todo> {
    return await this.todosService.createTodo(createTodoDto, user);
  }
}

↓これでタスク作成機能が完成しました。
Swaggerで確認してみましょう。

また、Swagger上でのユーザーアカウントのログイン状態の保持は以下の手順を踏みます。

ログイントークンを取得します。
スクリーンショット 2020-12-07 17.02.43.png

画面右上にあるAuthorizeボタンまたは各エンドポイント名の一番右にある鍵マークを押す
スクリーンショット 2020-12-07 17.01.33.png

スクリーンショット 2020-12-07 21.56.02.png

コピーしたトークンを貼り付け、Authorizeボタン押下でトークンをセットします。
スクリーンショット 2020-12-07 17.02.55.png

これでSwagger上でのログイン状態を保持できるようになりました。
スクリーンショット 2020-12-07 17.03.26.png

7.2. [API] タスク取得機能作成

タスク作成機能ができたのでそれによって作られるタスクの一覧/単体取得機能を実装します。
取得できた各タスクの作者情報(付随するUser)のPasswordは隠蔽しておきます。

一覧取得処理

todos/backend/src/todos/todos.service.ts
...

export class TodosService {
  ...

  async getTodos(): Promise<Todo[]> {
    return await this.todoRepository.find({
      relations: ['user'],
    });
  }
}

todos/backend/src/todos/todos.controller.ts
import {
  ...
  Get
} from '@nestjs/common';
...

export class TodosController {
  ...

  @Get()
  @ApiOkResponse({
    description: 'タスク一覧取得完了',
    type: [Todo],
  })
  async getTodos() {
    const todos = await this.todosService.getTodos();
    return todos.map(t => {
      delete t.user.password;
      return t;
    });
  }
}

単体取得処理

一覧取得と違い、単体取得の場合は対象のタスクが存在しなければNotFoundエラー扱いにします。

todos/backend/src/todos/todos.service.ts
...

@Injectable()
export class TodosService {
  ...

  async getTodo(id: number): Promise<Todo> {
    const todo = await this.todoRepository.findOne({
      where: { id },
      relations: ['user'],
    });
    if (!todo) {
      throw new NotFoundException('そのタスクは存在しません。');
    }
    return todo;
  }
}
todos/backend/src/todos/todos.controller.ts
import {
  ...
  Param,
  ParseIntPipe,
} from '@nestjs/common';
import {
  ...
  ApiNotFoundResponse,
} from '@nestjs/swagger';
...

export class TodosController {
  ...

  @Get(':id')
  @ApiOkResponse({
    description: 'タスク単体取得完了',
    type: Todo,
  })
  @ApiNotFoundResponse({
    description: '指定のタスクが存在しない',
  })
  async getTodo(@Param('id', ParseIntPipe) id: number) {
    const todo = await this.todosService.getTodo(id);
    delete todo.user.password;
    return todo;
  }
}

7.3. [API] タスク更新 & 削除機能

最後に、タスクの更新 & 削除機能を実装します。
これらは、投稿者以外は行えないような処理が必要となります。

DTO作成

タスク更新時にはユーザーから入力値を受け取る為、これまでと同様にその受け皿となるDTOクラスが必要となります。
(内容はCreateTodoDtoと同じものです)

~/todos/backend $ touch src/todos/dto/update-todo.dto.ts
todos/backend/src/todos/dto/update-todo.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { CATEGORY } from '../../entities/todo.entity';

export class UpdateTodoDto {
  @ApiProperty({
    example: 'AdventCalenderの記事書く',
    type: String,
  })
  @IsString()
  @IsNotEmpty()
  title: string;

  @ApiProperty({
    example: CATEGORY.GENERAL,
    type: 'enum',
  })
  @IsEnum(CATEGORY)
  @IsNotEmpty()
  category: CATEGORY;
}

更新/削除処理は投稿者本人しか操作できないようにする為、DB操作の前にユーザー識別処理を挿入します。
ユーザー識別処理は更新/削除処理API間で共通のものを使うため、Repository層でまとめておきます。

todos/backend/src/entities/repositories/todo.repository.ts
...
import { User } from '../user.entity';
import { UnauthorizedException } from '@nestjs/common';

@EntityRepository(Todo)
export class TodoRepository extends Repository<Todo> {
  ...

  async getOwnTodo(id: number, user: User): Promise<Todo> {
    const todo = await this.findOne({ id });
    if (!todo) {
      throw new NotFoundException('そのタスクは存在しません。');
    }
    if (todo.userId !== user.id) {
      throw new UnauthorizedException(
        '投稿者本人以外はタスクを更新できません。',
      );
    }
    return todo;
  }
}

更新処理

todos/backend/src/todos/todos.service.ts
...
import { UpdateTodoDto } from './dto/update-todo.dto';

export class TodosService {
  ...

  async updateTodo(
    id: number,
    { title, category }: UpdateTodoDto,
    user: User,
  ): Promise<Todo> {
    const todo = await this.todoRepository.getOwnTodo(id, user);
    todo.title = title;
    todo.category = category;
    return await todo.save();
  }
}
todos/backend/src/todos/todos.controller.ts
import {
  ...
  Put,
} from '@nestjs/common';
import {
  ...
  ApiNotFoundResponse,
  ApiUnauthorizedResponse
} from '@nestjs/swagger';
...
import { UpdateTodoDto } from './dto/update-todo.dto';

export class TodosController {
  ...

  @Put(':id')
  @ApiOkResponse({
    description: 'タスク更新完了',
    type: Todo,
  })
  @ApiNotFoundResponse({
    description: '指定のタスクが存在しない',
  })
  @ApiUnauthorizedResponse({
    description: '投稿者本人以外による操作',
  })
  async updateTodo(
    @Param('id', ParseIntPipe) id: number,
    @Body(ValidationPipe) updateTodoDto: UpdateTodoDto,
    @GetUser() user: User,
  ): Promise<Todo> {
    return await this.todosService.updateTodo(id, updateTodoDto, user);
  }
}

削除処理

削除処理の場合は成功時voidを返します。

todos/backend/src/todos/todos.service.ts
...

export class TodosService {
  ...

  async deleteTodo(id: number, user): Promise<void> {
    const todo = await this.todoRepository.getOwnTodo(id, user);
    await todo.remove();
  }
}
backend/src/todos/todos.controller.ts
import {
  ...
  Delete,
} from '@nestjs/common';
import {
  ...
  ApiNoContentResponse,
} from '@nestjs/swagger';
...

export class TodosController {
  ...

  @Delete(':id')
  @ApiNoContentResponse({
    description: 'タスク削除完了',
  })
  @ApiNotFoundResponse({
    description: '指定のタスクが存在しない',
  })
  @ApiUnauthorizedResponse({
    description: '投稿者本人以外による操作',
  })
  async deleteTodo(
    @Param('id', ParseIntPipe) id: number,
    @GetUser() user: User,
  ): Promise<void> {
    await this.todosService.deleteTodo(id, user);
  }
}

以上ですべてのAPIが完成しました。(めちゃくちゃ長くなってすみません)

8. エラー対応リスト

1. bcrypt関連

Error loading shared library /usr/src/app/node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node: Exec format error

と出た場合は、ホスト上のnode_modules/を削除してnpm iし直す。

2. マイグレーションファイルを作り直したのにmigration-runが効かない

DB上の関連テーブルを削除し直す。

■おわりに

基本的なAPIの作成手順、Passportによる認証ガードの実装手順、TypeORMによるDB操作の主な例が1つの記事に纏まることで、
すぐにでもLaravel等と同じ感覚で使っていけるようになれるのでは?という安易な気持ちから書き始めた記事でしたが、
それ+できる限り丁寧さを重視した差分形式で書いていった結果、総執筆時間がとんでもないことになりました…

次参加するときがあればお手頃なテーマで書きたいと思います。対戦ありがとうございました。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
28
Help us understand the problem. What are the problem?