Nest(TypeScript)で遊んでみる 〜REST API(CRUD)編〜
Posted on September 11, 2018 at 19:45 (JST)
今回はNestにてREST APIを作成する方法について記載する。
Todoリスト管理に用いるCRUD用APIを想定し、作成した。
入力チェックや例外処理は本記事では触れない。
手順は下記の通り。
- Srvice(Provider)の作成
- Controllerの作成
- Moduleの作成
- テスト
作成したコード [ 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
:id
は RouterParameter
となる。
Parameter binding
デコレーターを記述することによりRequestの値を取得することができる。
DTOに該当する値がない場合や、JSONで異なる型の場合でも無理やりマッピングしてエラーとはならない点に注意が必要。
stringのフィールドにnumberが入ったり、union型でnull等を指定していないのにundefinedになる。
bindは型キャストではなく型アサーションで行われているためだと推測している。(コードは読んでいない)
Response
NestにはResponseの作成方法が2つある。
Standard
推奨されている方法。
レスポンスBodyはメソッドの戻り値をJSONにしたものとなり、HttpStatusは200となる。
statusは@HttpCode
デコレーターで変換できる。
異常系のレスポンスはHttpException
やそのサブクラスを利用し、filterでレスポンス生成する。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'.
以上。