Open14
REST APIをTypescript(NestJS)でつくる

環境構築
nest new backend
# select npm
cd backend
npm run start
REST API構築
nest g resource tasks
VSCode拡張機能
ESLintとPrettierを入れる
settings.jsonを開いて、以下のように変更する。
settings.json
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" // フォーマッタをprettierに指定
},
"editor.formatOnSave": true, // 保存時にフォーマット
}
formatter設定
.eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};
.prettierrc
{
"singleQuote": true,
"trailingComma": "es5",
"semi": true,
"tabWidth": 2
}

dto
npm i --save class-validator class-transformer
tasks/dto/create-task.dto.ts
import { IsNotEmpty } from 'class-validator';
export class CreateTaskDto {
@IsNotEmpty()
title: string;
@IsNotEmpty()
description: string;
}
get-tasks-filter.dto.ts
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { TaskStatus } from '../task-status.enum';
export class GetTasksFilterDto {
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsString()
search?: string;
}
update-tasks-stautus.dto.ts
import { IsEnum } from 'class-validator';
import { TaskStatus } from '../task-status.enum';
export class UpdateTaskStatusDto {
@IsEnum(TaskStatus)
status: TaskStatus;
}

TypeORM
npm install --save @nestjs/typeorm typeorm@0.2 mysql2
tasks.repository.ts
import { EntityRepository, Repository } from 'typeorm';
import { CreateTaskDto } from './dto/create-task.dto';
import { GetTasksFilterDto } from './dto/get-tasks-filter.dto';
import { TaskStatus } from './task-status.enum';
import { Task } from './entities/task.entity';
@EntityRepository(Task)
export class TasksRepository extends Repository<Task> {
async getTasks(filterDto: GetTasksFilterDto): Promise<Task[]> {
const { status, search } = filterDto;
const query = this.createQueryBuilder('task');
if (status) {
query.andWhere('task.status = :status', { status });
}
if (search) {
query.andWhere(
'LOWER(task.title) LIKE LOWER(:search) OR LOWER(task.description) LIKE LOWER(:search)',
{ search: `%${search}%` }
);
}
const tasks = await query.getMany();
return tasks;
}
async createTask(createTaskDto: CreateTaskDto): Promise<Task> {
const { title, description } = createTaskDto;
const task = this.create({
title,
description,
status: TaskStatus.OPEN,
});
await this.save(task);
return task;
}
}

Controller
tasks.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto';
import { GetTasksFilterDto } from './dto/get-tasks-filter.dto';
import { UpdateTaskStatusDto } from './dto/update-task-status.dto';
import { TaskStatus } from './task-status.enum';
import { Task } from './entities/task.entity';
import { TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
constructor(private tasksService: TasksService) {}
@Get()
getTasks(@Query() filterDto: GetTasksFilterDto): Promise<Task[]> {
return this.tasksService.getTasks(filterDto);
}
@Get('/:id')
getTaskById(@Param('id') id: string): Promise<Task> {
return this.tasksService.getTaskById(id);
}
@Post()
createTask(@Body() createTaskDto: CreateTaskDto): Promise<Task> {
return this.tasksService.createTask(createTaskDto);
}
@Delete('/:id')
deleteTask(@Param('id') id: string): Promise<void> {
return this.tasksService.deleteTask(id);
}
@Patch('/:id/status')
updateTaskStatus(
@Param('id') id: string,
@Body() updateTaskStatusDto: UpdateTaskStatusDto
): Promise<Task> {
const { status } = updateTaskStatusDto;
return this.tasksService.updateTaskStatus(id, status);
}
}

Service
tasks.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { TaskStatus } from './task-status.enum';
import { CreateTaskDto } from './dto/create-task.dto';
import { GetTasksFilterDto } from './dto/get-tasks-filter.dto';
import { TasksRepository } from './tasks.repository';
import { InjectRepository } from '@nestjs/typeorm';
import { Task } from './entities/task.entity';
@Injectable()
export class TasksService {
constructor(
@InjectRepository(TasksRepository)
private tasksRepository: TasksRepository
) {}
getTasks(filterDto: GetTasksFilterDto): Promise<Task[]> {
return this.tasksRepository.getTasks(filterDto);
}
async getTaskById(id: string): Promise<Task> {
const found = await this.tasksRepository.findOne(id);
if (!found) {
throw new NotFoundException(`Task with ID "${id}" not found`);
}
return found;
}
createTask(createTaskDto: CreateTaskDto): Promise<Task> {
return this.tasksRepository.createTask(createTaskDto);
}
async deleteTask(id: string): Promise<void> {
const result = await this.tasksRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`Task with ID "${id}" not found`);
}
}
async updateTaskStatus(id: string, status: TaskStatus): Promise<Task> {
const task = await this.getTaskById(id);
task.status = status;
await this.tasksRepository.save(task);
return task;
}
}

Module
tasks.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksController } from './tasks.controller';
import { TasksRepository } from './tasks.repository';
import { TasksService } from './tasks.service';
@Module({
imports: [TypeOrmModule.forFeature([TasksRepository])],
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}

Docker
ローカルでテストするMySQLコンテナを立ち上げる。
docker-compose.yml
version: "3"
services:
mysql:
image: mysql:5.7
container_name: mysql_container
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test
TZ: "Asia/Tokyo"
ports:
- "3306:3306"
docker compose up

APIテスト
DBコンテナおよびバックエンドを立ち上げる。
docker compose up
npm run start
PostManでテスト
POST
GET
POSTで投入したデータを確認できた。
DELETE
GET
DELETEも正常に動作したことを確認。

環境変数準備
npm i --save @nestjs/config
touch .env.stage.prod
touch .env.stage.dev
app.module.ts
...
@Module({
imports: [
ConfigModule.forRoot({
envFilePath: [`.env.stage.${process.env.STAGE}`],
}),
...

DB接続情報を外だし
.env.stage.dev
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=root
DB_DATABASE=test
app.module.ts
...
@Module({
imports: [
ConfigModule.forRoot({
envFilePath: [`.env.stage.${process.env.STAGE}`],
}),
TasksModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
type: 'mysql',
autoLoadEntities: true,
synchronize: true,
host: configService.get('DB_HOST'),
port: configService.get('DB_PORT'),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_DATABASE'),
}),
}),
],
})
...

Schema Validation
npm i --save joi
config.schema.ts
import * as Joi from 'joi';
export const configValidationSchema = Joi.object({
NODE_ENV: Joi.string().valid('dev', 'prod').default('dev'),
STAGE: Joi.string().required(),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.string().default(3306).required(),
DB_USERNAME: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_DATABASE: Joi.string().required(),
});
app.module.ts
import { configValidationSchema } from './config.schema';
...
@Module({
imports: [
ConfigModule.forRoot({
envFilePath: [`.env.stage.${process.env.STAGE}`],
validationSchema: configValidationSchema,
})
...

Heroku セットアップ
Heroku CLIをインストール
npm i -g heroku
heroku -v
Heroku ログイン
heroku login
できた
Heroku上に無料のMySQLを用意する
heroku addons:create cleardb:ignite -a [Heroku App Name]
Herokuにデプロイするためにソースを修正
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());
const port = process.env.PORT;
await app.listen(port);
}
bootstrap();
config.schema.ts
import * as Joi from 'joi';
export const configValidationSchema = Joi.object({
PORT: Joi.number().default(3000),
NODE_ENV: Joi.string().valid('dev', 'prod').default('dev'),
STAGE: Joi.string().required(),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.string().default(3306).required(),
DB_USERNAME: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_DATABASE: Joi.string().required(),
});
app.module.ts
import { configValidationSchema } from './config.schema';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [
ConfigModule.forRoot({
envFilePath: [`.env.stage.${process.env.STAGE}`],
validationSchema: configValidationSchema,
}),
TasksModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
const isProduction = configService.get('STAGE') === 'prod';
return {
ssl: isProduction,
extra: {
ssl: isProduction ? { rejectUnauthorized: false } : null,
},
type: 'mysql',
autoLoadEntities: true,
synchronize: true,
host: configService.get('DB_HOST'),
port: configService.get('DB_PORT'),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_DATABASE'),
};
},
}),
],
})
export class AppModule {}
HerokuのリモートGitリポジトリを追加する
heroku git:remote -a [Heroku App Name]
HerokuのConfigを変更する
heroku config:set NPM_CONFIG_PRODUCTION=false
heroku config:set NODE_ENV=production
heroku config:set STAGE=prod
heroku config:set DB_HOST=us-cdbr-east-05.cleardb.net
heroku config:set DB_PORT=3306
heroku config:set DB_USER=bac****
heroku config:set DB_PASSWORD=****
heroku config:set DB_DATABASE=heroku_ba***

Herokuデプロイ
Herokuの実行ファイルを作成する
echo "web: npm run start:prod" > Procfile
これらの変更をGitコミット
git add .
git commit -m "Heroku Deploy"
HerokuのリモートGitリポジトリにpush
git push heroku master
ログを確認する
heroku logs --tail

OpenAPI
Install
npm install --save @nestjs/swagger swagger-ui-express
main.tsにコードを追記
main.ts
const config = new DocumentBuilder()
.setTitle('API example')
.setDescription('API description')
.setVersion('1.0')
.addTag('poc')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);