アレについて記す

Nest(TypeScript)で遊んでみる 〜REST API(CRUD)編〜

Posted on September 11, 2018 at 19:45 (JST)

今回はNestにてREST APIを作成する方法について記載する。
Todoリスト管理に用いるCRUD用APIを想定し、作成した。
入力チェックや例外処理は本記事では触れない。

手順は下記の通り。

  1. Srvice(Provider)の作成
  2. Controllerの作成
  3. Moduleの作成
  4. テスト

作成したコード [ nest-angular-sample: works/03_rest_api_crud ]

動作環境

OS: macOS High Sierra ver. 10.13.4
Nodejs: v8.10.0
npm: 5.6.0
nest: 5.4.0

1. Srvice(Provider)の作成

前準備

entityに割り当てるID生成のために uuid をインストール。

$ npm install --save uuid
$ npm install --save @types/uuid

entityとdtoを用意

NestではRequestをマッピングするクラスをDTOと呼ぶのが一般的らしい。 各モジュール(Controller)の dto ディレクトリに格納する。

[src/tasks/dto/task.dto.ts]

export class CreateTaskDto {
  readonly overview: string;
  readonly priority: number;
  readonly deadLine: Date;
}

export class UpdateTaskDto {
  readonly id: string;
  readonly overview: string;
  readonly priority: number;
  readonly deadLine: Date;
}

entityに該当するものはinterfaceにて表現しているようなので、公式ドキュメントに合わせた。

[src/tasks/interfaces/task.interface.ts]

export interface Task {
  readonly id: string;
  readonly overview: string;
  readonly priority: number;
  readonly deadLine: Date;
}

Serviceを用意

とりあえずフィールドにタスクを溜め込む実装を行った。
(後々DBアクセスに変更する予定)

update メソッドにてidに該当するものがない場合にExceptionを投げている。
ここはエラーを表現するオブジェクトを用意するか、専用のExceptionを用意したほうが良いのだが割愛。

[src/tasks/tasks.service.ts]

import { Injectable } from '@nestjs/common';
import { Task } from './interfaces/task.interface';
import { v4 as uuidV4 } from 'uuid';
import { Logger } from '../logger/logger.service';
import { CreateTaskDto, UpdateTaskDto } from './dto/task.dto';

@Injectable()
export class TasksService {
  constructor(private readonly logger: Logger) {}

  tasks: Task[] = [
    {
      id: '6a414c88-4613-486d-9990-80c1de52eea4',
      overview: 'Learn TypeScript',
      priority: 1,
      deadLine: new Date('2018-09-10T08:55:28.087Z'),
    },
    {
      id: 'd8a4132e-72ec-490c-b5f5-a8bbc4509be6',
      overview: 'Learn Node.js',
      priority: 2,
      deadLine: new Date('2018-09-11T07:41:59.711Z'),
    },
  ];

  findAll(): Promise<Task[]> {
    return new Promise(resolve => {
      resolve(this.tasks);
    });
  }

  findById(id: string): Promise<Task | null> {
    return new Promise(resolve => {
      resolve(this.tasks.find(t => t.id === id));
    });
  }

  create(dto: CreateTaskDto): Promise<Task> {
    return new Promise(resolve => {
      const id = uuidV4();
      const task = { id, ...dto };
      this.tasks.push(task as Task);
      resolve(task);
    });
  }

  async update(dto: UpdateTaskDto): Promise<Task> {
    const saved = await this.findById(dto.id);
    if (saved == null) {
      throw new Error(`Task not found. [id: ${dto.id}]`);
    }

    const index = this.tasks.findIndex(t => t.id === saved.id);
    this.tasks.splice(index, 1);
    const task: Task = dto as Task;
    this.tasks.push(task);
    return task;
  }

  destroy(id: string): Promise<void> {
    return new Promise(resolve => {
      const index = this.tasks.findIndex(t => t.id === id);
      if (!!index) {
        this.tasks.splice(index, 1);
      }
      resolve();
    });
  }
}

2. Controllerの作成

[src/tasks/tasks.controller.ts]

import {Get,Post,Put,Delete,Body,Param,HttpStatus,Controller,HttpCode,HttpException} from '@nestjs/common';
import { TasksService } from './tasks.service';
import { Logger } from '../logger/logger.service';
import { CreateTaskDto, UpdateTaskDto } from './dto/task.dto';

@Controller('tasks')
export class TasksController {
  constructor(
    private readonly tasksService: TasksService,
    private readonly logger: Logger,
  ) {}

  @Get()
  async index() {
    const tasks = await this.tasksService.findAll();
    return { tasks };
  }

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body('task') dto: CreateTaskDto) {
    this.logger.debug('create: ' + JSON.stringify(dto));

    const created = await this.tasksService.create(dto);
    return { task: created };
  }

  @Put()
  async update(@Body('task') dto: UpdateTaskDto) {
    this.logger.debug('update: ' + JSON.stringify(dto));

    return await this.tasksService.update(dto).catch(error => {
      throw new HttpException(
        `Task not found. [id: ${dto.id}]`,
        HttpStatus.BAD_REQUEST,
      );
    });
  }

  @Delete(':id')
  async destroy(@Param('id') id: string) {
    this.logger.debug('delete: ' + JSON.stringify(id));

    await this.tasksService.destroy(id);
    return;
  }
}

@Controllerデコレーターや @Get/@Post/@Put/@Delete デコレーターなどでパスを指定できる。
上記のソースは下記のエンドポイントとなる

GET /tasks 
POST /tasks
PUT /tasks
DELETE /tasks/:id

:idRouterParameter となる。

Parameter binding

デコレーターを記述することによりRequestの値を取得することができる。
DTOに該当する値がない場合や、JSONで異なる型の場合でも無理やりマッピングしてエラーとはならない点に注意が必要。
stringのフィールドにnumberが入ったり、union型でnull等を指定していないのにundefinedになる。
bindは型キャストではなく型アサーションで行われているためだと推測している。(コードは読んでいない)

Response

NestにはResponseの作成方法が2つある。

  1. Standard
    推奨されている方法。
    レスポンスBodyはメソッドの戻り値をJSONにしたものとなり、HttpStatusは200となる。
    statusは @HttpCode デコレーターで変換できる。
    異常系のレスポンスは HttpException やそのサブクラスを利用し、filterでレスポンス生成する。

  2. Library-specific
    @Res() デコレーターを使う方法。

同一ハンドラ(エンドポイントとなるメソッド内)で1と2を混ぜると挙動がおかしくなるためどちらか一方を選択すること。

3. Moduleの作成

[src/tasks/tasks.module.ts]

import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
import { LoggerModule } from '../logger/logger.module';

@Module({
  imports: [LoggerModule],
  controllers: [TasksController],
  providers: [TasksService],
})
export class TasksModule {}

他のModuleを利用する場合は imports に指定する。
作成したModuleをベースモジュール(AppModule)の imports に追加するのを忘れがちなので要注意。

[src/app.module.ts]

import { Module } from '@nestjs/common';
import { TasksModule } from './tasks/tasks.module';
import { LoggerModule } from './logger/logger.module';

@Module({
  imports: [
    LoggerModule,
    TasksModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

4. テスト

[src/tasks/tasks.controller.spec.ts]

import { Test, TestingModule } from '@nestjs/testing';
import { TasksController } from './tasks.controller';
import { Logger } from '../logger/logger.service';
import { TasksService } from './tasks.service';
import { CreateTaskDto, UpdateTaskDto } from './dto/task.dto';

describe('TasksController', () => {
  let app: TestingModule;

  beforeAll(async () => {
    app = await Test.createTestingModule({
      controllers: [TasksController],
      providers: [Logger, TasksService],
    }).compile();
  });

  describe('#index', () => {
    it('should return 2 tasks', async () => {
      const sut = app.get<TasksController>(TasksController);
      const actual = await sut.index();
      expect(actual.tasks).toHaveLength(2);
    });
  });

  describe('#create', () => {
    it('should return a task having an id', async () => {
      const sut = app.get<TasksController>(TasksController);
      const param = {
        overview: 'Learn TypeScript',
        priority: 1,
        deadLine: new Date('2018-09-10T08:55:28.087Z'),
      } as CreateTaskDto;
      const actual = await sut.create(param);
      expect(actual.task.id).toBeDefined();
    });
  });

  describe('#update', () => {
    it('should return a task updated', async () => {
      const sut = app.get<TasksController>(TasksController);
      const param = {
        id: '6a414c88-4613-486d-9990-80c1de52eea4',
        overview: 'Learn TypeScript',
        priority: 1,
        deadLine: new Date('2018-10-10T08:55:28.087Z'),
      } as UpdateTaskDto;
      const actual = await sut.update(param);
      expect(actual.deadLine).toBe(param.deadLine);
    });

    it('should throw exception when a task is not found', async () => {
      const sut = app.get<TasksController>(TasksController);
      const param = {
        id: 'not-exist',
        overview: 'Learn TypeScript',
        priority: 1,
        deadLine: new Date('2018-10-10T08:55:28.087Z'),
      } as UpdateTaskDto;

      await sut.update(param).catch(error => {
        expect(error.stack).toContain('Task not found.');
      });
    });
  });

  describe('#destroy', () => {
    it('should delete a task', async () => {
      const sut = app.get<TasksController>(TasksController);
      const targetId = '6a414c88-4613-486d-9990-80c1de52eea4';
      const beforeSize = (await sut.index()).tasks.length;
      await sut.destroy(targetId);

      const afterSize = (await sut.index()).tasks.length;
      expect(beforeSize).toBe(afterSize + 1);
    });
  });
});

Angularと同様、単体(単機能/small)テストはプロダクションコードと同じディレクトリに配備する。
$ npm run test で実行できる。


余談だが、tsconfig.json がルートディレクトリに存在しない状態でVSCodeを使ってテストクラスを開くと下記エラーが表示されるが、テストは問題なく実行できる。

[ts] Cannot find name 'describe'.

以上。


参考URL