🍧

NestJS(+ React.js)で簡単なTODOアプリを作ってみる

2022/06/23に公開

こんにちは。株式会社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アプリを作っていこう!

レポジトリ

https://github.com/tkcel/todo-app-nest-react/tree/main

環境

  • 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?
      npmyarn
      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.tsimportItemsModuleが自動で入っていることも確認する
      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.tscontrollerItemsControllerが自動で入っていることも確認する

      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)ことで、ユースケースを実現する。実装イメージとしては、

    1. サービスをコントローラから呼び出す
    2. コントローラでサービスで実装されたものを使いながらルーティングを記述する
    • この段階で、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つの手順が必要になる

    1. Moduleのprovidersに仕様したいServiceを登録する
    2. 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.jsonscriptsにコマンドが記載されている)

      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;
    }
    
  • StatusEnum./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の主要機能の一つであるPipeclass-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メソッドで、送られるBodystatusに対して、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の基本からセキュリティや認証やバリデーションなどを勉強できたのでサーバーサイドの入門としてもとても良い教材でした!今回のチュートリアルもかなりこちらの教材を参考にさせていただいております。ぜひ購入をおすすめ致します!

https://www.udemy.com/course/nestjs-t/

  • 公式Docsの日本語訳(ありがたい…ただ現在NestJSの最新がV8ので注意)

https://zenn.dev/kisihara_c/books/nest-officialdoc-jp

  • この本でGraphQL(Prismaを使って)バージョンのチュートリアルもやってみた。コンフィグレーション周りやCloud Run, microCMSなどを使っており、内容盛りだくさんで大変勉強になりました…!

https://zenn.dev/waddy/books/graphql-nestjs-nextjs-bootcamp

Red Frasco

Discussion