NestJSでAPI開発を行う

こんにちは、次世代デジタル基盤開発事業部の野口です。 関わったプロジェクトで以下の構成でexpressでバックエンド開発をしたので、そのアウトプットとして記事を書きます。

  • express
  • routing-controllers
  • inversify(DI)
  • typeorm

ただ、この環境を作るのが難しそうなので、もっと簡単にCLI一発で似たような環境が作れるNestJSで作成していきたいと思います。本当はNestJSを触ってみたかっただけですが、、、

nestjs.com

NestJSはNode.js上で動作するオープンソースのバックエンド開発フレームワークで expressをコアにして作られています。 詳しくは以下を読むと分かりやすいです。
NestJSにざっくり入門してみる

今回はtodoListのCRUD操作と簡単な検索機能を作成することをゴールとしたいと思います。

環境構築

  • 開発環境

ubuntu-20.04
node v18.2.0

まずは、以下の手順で環境構築を行っていきます

  • Nest CLIをインストール
npm i -g @nestjs/cli
  • プロジェクトの作成
nest new todo-list

image.png

npmを選択します

以下のフォルダが生成されます。

image.png

以下のファイルは使用しないので削除してしまって大丈夫です。

  • app.controller.spec.ts
  • app.controller
  • app.service

todoListのモジュール、コントローラー、サービスを作ります。 nestではCLIで作成できるのでターミナルから実行します。

nest g module todos 
nest g controller todos --no-spec
nest g service todos --no-spec

ちなみに、--no-spec はテストファイルを作成しないという意味なのでテストファイルが欲しい場合はなしでOKです。

  • typeormのインストール
npm install typeorm@0.2.45 @nestjs/typeorm pg -f

補足: ドキュメント的にはnpm install --save @nestjs/typeorm typeorm pgが正解だと思うのですが、下記に記述するマイグレーションファイルが上手く作成できないためバージョンを0.2.45に指定します。
typeORMが3になってから大幅に変更があったみたいで、マイグレーションファイルの作成がうまくできないみたいです。
これは、自分の環境が原因なのか、typeormが原因なのか切り分けができていないので、後で色々試してみないとですね。
NestJSの記事は新しいのがあまりないので情報が少ないのがネックですね。

  • DBの設定 dockerでローカルのデータベースを起動します。 dockerが入っていない場合はdockerをインストールしてください。 https://docs.docker.com/get-docker/ プロジェクトの直下にdocker-compose.yamlを作成し、以下でdocker-compose up -dをします。

docker-compose.yaml

version: '3.7'
services:
  postgres:
    image: postgres:12.2-alpine
    container_name: postgres
    ports:
      - 5432:5432
    volumes:
      - ./docker/postgres/init.d:/docker-entrypoint-initdb.d
      - ./docker/postgres/pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_INITDB_ARGS: '--encoding=UTF-8'
      POSTGRES_DB: postgres
    hostname: postgres
    restart: always
    user: root

DBに接続できるかテストしましょう。 以下の状態でnpm run start:devをしてapiを起動します。
app.module.ts

import { Module } from '@nestjs/common';
import { TodosModule } from './todos/todos.module';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TodosModule,
    // 追加
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'postgres',
      database: 'postgres',
      autoLoadEntities: true,
    }),
    // 追加
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

問題なければ以下のようになります。 image.png

portが違ったりすると以下のようにDBに接続できないエラーが出ます。 image.png

Entityを定義

テーブルを作成するためにEntityを定義します。 src > entities > todo.entity.tsを作成する。
todo.entity.ts

import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class Todo {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  content: string;

  @Column()
  contentType: string;

  @Column()
  createAt: string;

  @Column()
  updateAt: string;
}

DBマイグレーション

マイグレーションを行うことでDBにテーブルを作成していきます。先ほどのEntityからマイグレーションファイルを作成します。 マイグレーションファイルを作成する前にormconfig.jsでtypeormの設定を記述します。これによってマイグレーションファイル作成場所を指定できます。
ormconfig.js

  type: 'postgres',
  username: 'postgres',
  password: 'postgres',
  entities: ['dist/entities/*.entity.js'],
  migrations: ['dist/migrations/*.js'],
  cli: {
    entitiesDir: 'src/entities',
    migrationsDir: 'src/migrations',
  },
};
  • マイグレーションファイルの作成 apiを実行することによってマイグレーションをコンパイルできます。
npx typeorm migration:generate -n CreateTodo
  • マイグレーションファイルのコンパイル 作成したマイグレーションファイルをコンパイルします。
npm run start:dev
  • マイグレーションの実行 これでtodosテーブルが作成されます。
npx typeorm migration:run

todoList機能の実装

src > todost直下の以下の3つのフォルダで実装していきます。

  • todos.controller.ts
  • todos.module.ts
  • todos.service.ts

以下のような関係になっています
  main.ts
   ↓
  app.module.ts
   ↓
  todo.module.ts
   ↓     ↓
todo.service.ts ← todo.controller.ts

  • バリデーション

先ずは、todoListの機能を作成する前に、リクエストのバリデーションをしたいので、class-validatorclass-transformerをインストールします。

npm install --save class-validator class-transformer
  • DTO
    todos > dto > create-todoを作成し、以下でバリデーションを行います。
    stringでないまたは空の場合はリクエストエラーが入るようにしています。

create-todo.dto.ts

import { IsNotEmpty, IsString } from 'class-validator';

export class CreateTodoDto {
  @IsString()
  @IsNotEmpty()
  content: string;

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

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(3000)
}
bootstrap()
  • CRUDの実装

以下の機能を実装していきます。
search
create
update
delete

constructor(private readonly todosService: TodosService) {}をすることでserviceのDIを行っています。
todos.controller.ts

import {
  Body,
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Param,
} from '@nestjs/common'
import { Todo } from 'src/entities/todo.entity'
import {
  CreateTodoDto,
  SearchTodoDto,
  UpdateTodoDto,
} from './dto/create-todo.dto'
import { TodosService } from './todos.service'

@Controller('todos')
export class TodosController {
  constructor(private readonly todosService: TodosService) {}
  @Get()
  async search(@Body() searchTodoDto: SearchTodoDto): Promise<Todo[]> {
    return this.todosService.search(searchTodoDto)
  }

  @Post()
  async create(@Body() createTodoDto: CreateTodoDto): Promise<void> {
    await this.todosService.create(createTodoDto)
  }

  @Patch()
  async update(@Body() updateTodoDto: UpdateTodoDto): Promise<void> {
    await this.todosService.update(updateTodoDto)
  }

  @Delete(':id')
  async delete(@Param('id') id: number): Promise<void> {
    await this.todosService.delete(id)
  }
}

serviceの中でconstructor( @InjectRepository(Todo) private todosRepository: Repository<Todo>, ) {}をすることでTypeORMでTodoのDB操作ができるようにしています。

todos.service.ts

import { Injectable, NotFoundException } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Todo } from 'src/entities/todo.entity'
import { Repository } from 'typeorm'
import {
  CreateTodoDto,
  SearchTodoDto,
  UpdateTodoDto,
} from './dto/create-todo.dto'

@Injectable()
export class TodosService {
  constructor(
    @InjectRepository(Todo)
    private todosRepository: Repository<Todo>,
  ) {}

  async search(searchTodoDto: SearchTodoDto): Promise<Todo[]> {
    const query = this.todosRepository.createQueryBuilder('todo')

    if (searchTodoDto.content) {
      query.where('todo.content like :content', {
        content: '%' + searchTodoDto.content + '%',
      })
    }

    if (searchTodoDto.contentType) {
      query.andWhere('todo.contentType like :contentType', {
        contentType: '%' + searchTodoDto.contentType + '%',
      })
    }

    const todo = query.getMany()
    if (!todo) {
      throw new NotFoundException()
    }
    return todo
  }

  async create(createTodoDto: CreateTodoDto): Promise<void> {
    const { content, contentType } = createTodoDto
    const todo = {
      content,
      contentType,
      createAt: new Date().toISOString(),
      updateAt: new Date().toISOString(),
    }
    await this.todosRepository.save(todo)
  }

  async update(updateTodoDto: UpdateTodoDto): Promise<void> {
    const todo = await this.todosRepository.findOne(updateTodoDto.id)
    if (!todo) {
      throw new NotFoundException()
    }
    if (updateTodoDto.content) {
      todo.content = updateTodoDto.content
    }
    if (updateTodoDto.contentType) {
      todo.contentType = updateTodoDto.contentType
    }
    if (updateTodoDto.content || updateTodoDto.contentType) {
      todo.updateAt = new Date().toISOString()
    }
    await this.todosRepository.save(todo)
  }

  async delete(id: number): Promise<void> {
    const todo = await this.todosRepository.findOne(id)
    if (!todo) {
      throw new NotFoundException()
    }
    await this.todosRepository.delete(todo)
  }
}

実際にpostmanで確認してみましょう。
createを呼んで、 image.png searchを呼ぶと、 image.png 先ほどcreateしたtodoが登録されています。

終わりに

NestJSを使用するとかなり簡単にAPI開発ができるなと思いました。

ただ、情報が少ないのが難点ですね、、、 皆さんのお役に立てれば幸いです。

tecotec.co.jp