NestJS(+ React.js)で簡単なTODOアプリを作ってみる
こんにちは。株式会社Red Frascoの根本(github: tkcel )と申します。普段はフロントエンドを主に担当することが多いです。
業務でNestJSを触る可能性が大になってきたので、ここらで簡単にNestJSに入門してみたいと思いました。(お恥ずかしながらサーバーサイドがあまり得意ではなく...)
NestJSに今後入門する方にとって、ファーストステップの軽いサンプルアプリ、チュートリアルとしてどなたかの役に立てたら嬉しいです。
- NestJSにとりあえず触れたい
- 軽く触って簡単なアプリ作れるいい感じの日本語のチュートリアルが欲しい
といった気持ちでこの記事を書いています…とにかくサクッと作れることを念頭に置いて書いているので、間違っている点や至らぬところがありましたらコメントでご指摘お願い致します🙇♂️
本チュートリアルのゴール
- ローカルで動く簡単なTODOアプリをNestJS + React.jsで作ってみる
本チュートリアルで扱わないこと
- フロントエンド(React.js)の解説
- 詳細なNestJSの説明
- ログイン、認証関係(Guard等)
- コンフィグレーション関係
NestJSの構造理解
- Figmaで簡単にNestJSの構造を書いてみました。
- NestJSのアーキテクチャの詳しい説明は公式のDocsか図の下の参考に示しているzennの記事が分かりやすかったのでそちらをご参照ください。
💡 参考: 「NestJS の基礎概念の図解と要約」 Shinya Fujino
実際にTODOアプリを作っていこう!
レポジトリ
環境
- node.js: 16.14.2
- yarn: 1.22.19
- docker: 20.10.8
- docker-compose: 1.29.2
構成
todo-app-nest-react # プロジェクトルート
├── backend # NestJSで構築
└── frontend # React.jsで構築
簡単な要件定義
-
それぞれのAPIのエンドポイントは以下の通りに設計する
METHOD URI DESCRIPTION GET http://localhost:3001/items TODOアイテムの全件取得 POST http://localhost:3001/items アイテムの作成 GET http://localhost:3001/items/{:id} 特定アイテムの取得 UPDATE http://localhost:3001/items/{:id} 特定アイテムの更新 DELETE http://localhost:3001/items/{:id} 特定アイテムの削除 -
TODO格納用の
items
テーブルは以下のようにしたid body status createdAt updatedAt test_id_1 サンプルタスク1 DONE 2022-06-22T14:05:59.521Z 2022-06-22T14:05:59.521Z test_id_2 サンプルタスク2 TODO 2022-06-22T14:05:59.521Z 2022-06-22T14:05:59.521Z test_id_3 サンプルタスク3 IN_PROGRESS 2022-06-22T14:05:59.521Z 2022-06-22T14:05:59.521Z
サーバーサイドの構築
ディレクトリを作成する
-
構成は以下で進める
todo-app-nest-react # プロジェクトルート ├── backend # NestJSで構築 ← まずはこっちを作っていく └── frontend # React.jsで構築
-
任意のディレクトリで以下を実行
mkdir todo-app-nest-react
NestJSでサーバー用プロジェクトを作成する
-
todo-app-nest-react
配下で、初期化yarn init --yes
-
nest-cli
のインストールyarn add -D @nestjs/cli
-
nestのプロジェクトの作成
yarn nest new backend ? Which package manager would you ❤️ to use? npm ❯ yarn pnpm
-
テストは今回飛ばすので、以下を削除しておく
app.controller.spec.ts
test/*
-
ポート番号を変えておく(フロント:
3000
, サーバー:3001
にする):./backend/src/main.ts
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); - await app.listen(3000); + await app.listen(3001); } bootstrap();
itemsモジュールの作成
-
items用のモジュールを作成する
-
./backend
配下で以下のnest-cliコマンドを実行し、itemsモジュールを作成するyarn nest g module item
-
items/item.module.ts
が作成されていることを確認する - また、
app.module.ts
のimport
にItemsModule
が自動で入っていることも確認するimport { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ItemsModule } from './items/items.module'; @Module({ imports: [ItemsModule], controllers: [AppController], providers: [AppService], }) export class AppModule {}
-
-
items/item.module.ts
の中身は現時点で以下の通り。後ほど中身を記述する。import { Module } from '@nestjs/common'; @Module({}) export class ItemsModule {}
itemsコントローラの作成
-
items用のコントローラを作成する
-
./backend
配下で以下のnest-cliコマンドを実行し、itemsコントローラを作成するyarn nest g controller items --no-spec
-
items/items.controller.ts
が作成されていることを確認する -
items/items.module.ts
のcontroller
にItemsController
が自動で入っていることも確認するimport { Module } from '@nestjs/common'; import { ItemsController } from './items.controller'; @Module({ controllers: [ItemsController], }) export class ItemsModule {}
-
-
items/items.controller.ts
の現時点での中身は以下の通り。サービスを実装した後中身を書いていく。import { Controller } from '@nestjs/common'; @Controller('items') export class ItemsController {}
itemsサービスを実装する
-
実際のCRUD操作などの処理(ビジネスロジック)はこちらに書いていく。コントローラから呼び出す(DI)ことで、ユースケースを実現する。実装イメージとしては、
- サービスをコントローラから呼び出す
- コントローラでサービスで実装されたものを使いながらルーティングを記述する
- この段階で、
DI(Dependency Injection)
について明るくない方は勉強しておくといいかも…?
-
例によって、
./backend
配下で以下のnest-cliコマンドを実行し、itemsサービスを作成するyarn nest g service items --no-spec
-
items/items.service.ts
が生成されていることを確認する -
現時点での
items/items.service.ts
の中身は以下import { Injectable } from '@nestjs/common'; @Injectable() export class ItemsService {}
-
-
まずは、仮でitemsサービスで以下のように仮で実装する(後ほどリファクタ)
import { Injectable } from '@nestjs/common'; @Injectable() export class ItemsService { findAll() { return 'findAll method called'; } }
コントローラからサービスを利用する
-
以下の2つの手順が必要になる
- Moduleのprovidersに仕様したいServiceを登録する
- Controllerのconstructorで該当のServiceを引数に取る
-
先ほど作成した
ItemsService
(1)をコントローラで呼び出し(2)APIを作成するimport { Controller, Get } from '@nestjs/common'; import { ItemsService } from './items.service'; @Controller('items') export class ItemsController { constructor(private readonly itemsService: ItemsService) {} // ① @Get() // ② findAll() { return this.itemsService.findAll(); } }
-
①NestJSがItemsServiceをインスタンス化し、変数に代入してくれる
constructor(private readonly itemsService: ItemsService) {}
-
②
http://localhost:3001/items
に対しGET
メソッドを呼び出すと、サービスで実装したfindAll()
が実行される@Get() findAll() { return this.itemsService.findAll(); }
-
-
これで、APIを一つ作ることができた
-
実際に作成したAPIが動作するか確認する
-
Postman等で叩いてみると、以下のようにレスポンスが返ってきていることが確認できる
-
./backendで以下のコマンドを実行し、サーバーを立ち上げる(
./package.json
のscripts
にコマンドが記載されている)yarn start:dev
- エラーが出ていなければうまく起動できている
-
Postmanから実際に叩いてみた結果は以下
-
無事にAPIが動作していることが確認できた🎉
-
TODO用のモデルを作成する
-
TODO用のモデルを作成していく
-
./backend/items/items.model.ts
を作成し、設計に従ってモデルを作成するimport { ItemStatus } from './item-status.enum'; export interface Item { id: string; body: string; status: ItemStatus; createdAt: string; updatedAt: string; }
-
Status
のEnum
は./backend/items/item-status.enum.ts
に吐き出すexport enum ItemStatus { TODO = 'TODO', IN_PROGRESS = 'IN_PROGRESS', DONE = 'DONE', }
各種APIを作成する
createを作成する
-
items.service.ts
を以下の通りに実装する(一緒にfindAll
メソッドも変えておく)import { Injectable } from '@nestjs/common'; import { Item } from './items.model'; @Injectable() export class ItemsService { private todoItems: Item[] = []; // ① findAll(): Item[] { return this.todoItems; } create(item: Item) { this.todoItems.push(item); // ② return item; // ③ } }
- ① DB機能を実装していないため、配列としてitemをもっておく
- ② 新規に追加されるitemを引数に持ち、それを①で定義した配列に格納する
- ③ ここで返される値がHTTP Responseとなる
-
items.controller.ts
を以下のように編集するimport { Body, Controller, Get, Post } from '@nestjs/common'; import { ItemStatus } from './item-status.enum'; import { Item } from './items.model'; import { ItemsService } from './items.service'; @Controller('items') export class ItemsController { constructor(private readonly itemsService: ItemsService) {} @Get() findAll(): Item[] { return this.itemsService.findAll(); } @Post() create(@Body('id') id: string, @Body('body') body: string): Item { // ① return this.itemsService.create({ id, body, status: ItemStatus.TODO, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); } }
- ①
@Body
デコレータでHTTP Requestの中から指定のKeyのデータを取り出すことができる
- ①
-
Postmanから実際にデータを作成できるか確認する
- データが作成され、取得できることが確認できた
その他のCRUDも実装する(詳細説明省略)
-
item.service.ts
を以下のように編集するimport { Injectable } from '@nestjs/common'; import { ItemStatus } from './item-status.enum'; import { Item } from './items.model'; @Injectable() export class ItemsService { private todoItems: Item[] = []; findAll(): Item[] { return this.todoItems; } findById(id: string): Item { return this.todoItems.find((item) => item.id === id); } create(item: Item): Item { this.todoItems.push(item); return item; } updateStatus(id: string, status: ItemStatus): Item { const targetItem = this.findById(id); targetItem.status = status; return targetItem; } delete(id: string): string { this.todoItems = this.todoItems.filter((item) => item.id !== id); return `item_id: ${id} delete success`; } }
-
item.controller.ts
を以下のように修正するimport { Body, Controller, Delete, Get, Param, Patch, Post, } from '@nestjs/common'; import { ItemStatus } from './item-status.enum'; import { Item } from './items.model'; import { ItemsService } from './items.service'; @Controller('items') export class ItemsController { constructor(private readonly itemsService: ItemsService) {} @Get() findAll(): Item[] { return this.itemsService.findAll(); } @Get(':id') findById(@Param('id') id: string): Item { return this.itemsService.findById(id); } @Post() create(@Body('id') id: string, @Body('body') body: string): Item { console.log(id); const item = { id, body, status: ItemStatus.TODO, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; return this.itemsService.create(item); } @Patch(':id') updateToDoStatus( @Param('id') id: string, @Body('status') status: ItemStatus, ): Item { return this.itemsService.updateToDoStatus(id, status); } @Delete(':id') delete(@Param('id') id: string): string { return this.itemsService.delete(id); } }
-
それぞれのAPIについてデバックをしてみて動作していることを確認する
バリデーションの実装
-
安全なデータ操作のためにバリデーションを実装する
-
NestJSの主要機能の一つである
Pipe
とclass-validator
,class-transformer
を使いながら進めていく(詳しい説明は省略する) -
まずは、必要ライブラリのインストール。
./backend
配下で以下のコマンドを実行するyarn add uuid class-validator class-transformer
class-validatorでバリデーションを実装する
-
まずは、クライアントサイドから送られるデータとの中継になる
dto
を作成する -
./backend/items/dto/create-item.ts
を作成するimport { IsNotEmpty, IsString, MaxLength } from 'class-validator'; export class CreateItemDto { @IsString() @IsNotEmpty() @MaxLength(2) id: string; @IsString() @IsNotEmpty() @MaxLength(256) body: string; }
- ① stringを判断するvalidator
- ② emptyではないことを判断するvalidator
- ③ 最大文字数を指定できるvalidator
- これ以外にもさまざまなvalidatorがある
-
./backend/main.ts
を以下のように修正するimport { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe()); await app.listen(3001); } bootstrap();
-
item.service.ts
を修正するimport { Injectable } from '@nestjs/common'; import { CreateItemDto } from './dto/create-item'; import { ItemStatus } from './item-status.enum'; import { Item } from './items.model'; @Injectable() export class ItemsService { private todoItems: Item[] = []; findAll(): Item[] { return this.todoItems; } findById(id: string): Item { return this.todoItems.find((item) => item.id === id); } create(createItem: CreateItemDto): Item { const item = { ...createItem, status: ItemStatus.TODO, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; this.todoItems.push(item); return item; } updateToDoStatus(id: string, status: ItemStatus): Item { const targetItem = this.findById(id); targetItem.status = status; return targetItem; } delete(id: string): string { this.todoItems = this.todoItems.filter((item) => item.id !== id); return `item_id: ${id} delete success`; } }
-
item.controller.ts
を修正するimport { Body, Controller, Delete, Get, Param, Patch, Post, } from '@nestjs/common'; import { CreateItemDto } from './dto/create-item'; import { ItemStatus } from './item-status.enum'; import { Item } from './items.model'; import { ItemsService } from './items.service'; @Controller('items') export class ItemsController { constructor(private readonly itemsService: ItemsService) {} @Get() findAll(): Item[] { return this.itemsService.findAll(); } @Get(':id') findById(@Param('id') id: string): Item { return this.itemsService.findById(id); } @Post() create(@Body() createItem: CreateItemDto): Item { return this.itemsService.create(createItem); } @Patch(':id') updateToDoStatus( @Param('id') id: string, @Body('status') status: ItemStatus, ): Item { return this.itemsService.updateToDoStatus(id, status); } @Delete(':id') delete(@Param('id') id: string): string { return this.itemsService.delete(id); } }
-
実際にバリデーションができているか確認する
-
createする際のidの最大の長さが2なので、’test_id_1’としてPostを送ってみる
- このようにバリデーションが効いていることが確認できた
-
-
お気づきの方もいるかもしれませんが、現状だと/
items/{id}
のPatch
メソッドで、送られるBody
のstatus
に対して、ItemStatus
かどうかバリデーションができていないので、余裕のある方はバリデーションをどこに実装すればいいのか含めて追加で実装してみてください。
Pipeでバリデーションする
-
まずは、
items/dto/create-item.ts
を以下のように修正するimport { IsNotEmpty, IsString, MaxLength } from 'class-validator'; export class CreateItemDto { @IsString() @IsNotEmpty() @MaxLength(256) body: string; }
-
item.service.ts
で以下のように、uuidで自動採番するように修正するimport { Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; import { CreateItemDto } from './dto/create-item'; import { ItemStatus } from './item-status.enum'; import { Item } from './items.model'; @Injectable() export class ItemsService { private todoItems: Item[] = []; findAll(): Item[] { return this.todoItems; } findById(id: string): Item { return this.todoItems.find((item) => item.id === id); } create(createItem: CreateItemDto): Item { const item = { id: uuid(), ...createItem, status: ItemStatus.TODO, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; this.todoItems.push(item); return item; } updateToDoStatus(id: string, status: ItemStatus): Item { const targetItem = this.findById(id); targetItem.status = status; return targetItem; } delete(id: string): string { this.todoItems = this.todoItems.filter((item) => item.id !== id); return `item_id: ${id} delete success`; } }
-
item.controller.ts
でパラメータに適用する方法でPipe
を記述するimport { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, } from '@nestjs/common'; import { CreateItemDto } from './dto/create-item'; import { ItemStatus } from './item-status.enum'; import { Item } from './items.model'; import { ItemsService } from './items.service'; @Controller('items') export class ItemsController { constructor(private readonly itemsService: ItemsService) {} @Get() findAll(): Item[] { return this.itemsService.findAll(); } @Get(':id') findById(@Param('id', ParseUUIDPipe) id: string): Item { return this.itemsService.findById(id); } @Post() create(@Body() createItem: CreateItemDto): Item { return this.itemsService.create(createItem); } @Patch(':id') updateToDoStatus( @Param('id', ParseUUIDPipe) id: string, @Body('status') status: ItemStatus, ): Item { return this.itemsService.updateToDoStatus(id, status); } @Delete(':id') delete(@Param('id', ParseUUIDPipe) id: string): string { return this.itemsService.delete(id); } }
-
Postmanから確認してみる
-
createして、適当に’test_id’としてgetしてみようとする
- uuid形式ではないというエラーが返ってきており、バリデーションが実装できていることを確認できた
-
PostgreSQLとPgAdminの環境設定
~記載中~
TypeORMの導入
~記載中~
Entityを作成しリファクタ
~記載中~
Repositoryの作成
~記載中~
クライアントサイドを立ち上げてアプリを確認してみる
~記載中~
参考
- このUdemy通りに一回やってみました。NestJSの基本からセキュリティや認証やバリデーションなどを勉強できたのでサーバーサイドの入門としてもとても良い教材でした!今回のチュートリアルもかなりこちらの教材を参考にさせていただいております。ぜひ購入をおすすめ致します!
- 公式Docsの日本語訳(ありがたい…ただ現在NestJSの最新がV8ので注意)
- この本でGraphQL(Prismaを使って)バージョンのチュートリアルもやってみた。コンフィグレーション周りやCloud Run, microCMSなどを使っており、内容盛りだくさんで大変勉強になりました…!
Red Frascoは、不動産テックの企業です。日本と海外で培った技術力・知見・業界内での繋がりをもって、1つでも業界の課題を解消できるよう挑戦し続けています。一緒に戦ってくださる方を募集しています(red-frasco.com/recruit/)。
Discussion