コードサンプル
ここで作成したコード全文は Github から確認できる(若干違うかも)
https://github.com/tktcorporation/nestjs-sample/tree/create-some-api-with-typeorm
DB の準備
Dockerfile
FROM node:12.16.1
RUN npm i -g @nestjs/cli ts-node
ENTRYPOINT ["/bin/bash", "-c"]
docker-compose
version: "3"
services:
app:
build: .
container_name: app
working_dir: /app
volumes:
- {プロジェクトディレクトリ}:/app
ports:
- "3000:3000"
environment:
- DB_HOST=db
- DB_PORT=5432
- DB_USER=root
- DB_PASSWORD=password
- DB_DATABASE_NAME=database
- NODE_ENV=development
depends_on:
- db
db:
image: postgres:11
ports:
- 5432:5432
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: password
POSTGRES_DB: database
POSTGRES_INITDB_ARGS: "--encoding=UTF-8"
hostname: postgres
restart: always
user: root
別段の記述がない限り、以降の操作はすべて Docker コンテナ内にて行う
必要なモジュールの追加
$ yarn add @nestjs/typeorm typeorm pg
(@nestjs/typeorm はいらないかも)
接続に必要な設定を書いていく
- nestjs 標準でついている連携機構(?)は使わない
- 標準でついている連携機構(?) -> TypeOrmModule.forRoot() をインポートする方法
- コネクション管理用のクラスを別で作成して使用する
- cli は ormconf.ts の設定を適用して実行する(ormconfig だとアプリ側で自動適用されたりしてよくわからなくなった)
もっと良い方法があればご教示お願いしたく…
(ちょちょいと書いたらおわりでしょーとか思ってたら見事に嵌ってしばらく抜け出せなかった)
dbconnection.ts
- コネクション管理用のクラスを別で作成して使用する
に当たる部分。
こんな感じ(TypeORM をシングルトンで使う) のファイルを作成する
ormconf.js
module.exports = {
type: 'postgres',
host: process.env.DB_HOST,
port: Number.parseInt(process.env.DB_PORT),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE_NAME,
synchronize: false,
logging: process.env.NODE_ENV === 'production' ? ['error'] : 'all',
migrations: ['src/migration/**/*.ts'],
cli: {
migrationsDir: 'src/migration',
},
};
マイグレーション
この記事では、こちら で書かれているような Repository パターンではなく、SQL を直接書く方法で DB を扱っていきます。
複雑な SQL を書く場合、O/R Mapper だとうまく書けないことが多いので、最近はこの方法で書くことが多いです。
ただ、実際の Table の型と SELECT してきたときの型が一致しないので、ちゃんとテストを書いておかないと Table の構成を変更したときのエラーを拾いきれなくなるかもです。
ファイル作成コマンド
$ ts-node $(npm bin)/typeorm migration:create -f ormconf -n {ファイル名}
このコマンドで00000000000000-{ファイル名}.ts
のファイルが作成される。
SQL を書いていく
import { MigrationInterface, QueryRunner } from 'typeorm';
export class {ファイル名}00000000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// この中に実行するSQLを書く
await queryRunner.query(`
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"name" VARCHAR(250) NOT NULL
);
`);
}
// この中には実行したSQLの動作を一つ戻すためのものを書く
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TABLE "users";
`);
}
}
マイグレーション実行
このコマンドで、作成したマイグレーションファイルを実行できる。
ts-node $(npm bin)/typeorm migration:run
こんな感じに表示されれば成功
migration 実行ログ
$ ts-node $(npm bin)/typeorm migration:run
query: SELECT * FROM "information_schema"."tables" WHERE "table_schema" = current_schema() AND "table_name" = 'migrations'
query: SELECT * FROM "migrations" "migrations" ORDER BY "id" DESC
0 migrations are already loaded in the database.
1 migrations were found in the source code.
1 migrations are new migrations that needs to be executed.
query: START TRANSACTION
query:
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"name" VARCHAR(250) NOT NULL
);
query: INSERT INTO "migrations"("timestamp", "name") VALUES ($1, $2) -- PARAMETERS: [1590802150528,"CreateUsersTable1590802150528"]
Migration CreateUsersTable1590802150528 has been executed successfully.
query: COMMIT
Done in 9.74s.
DBデータの操作
せっかくなので、API が呼ばれた時に INSERT, SELECT が走るようにする。
テストの実行
$ yarn test
補足
これ以降の実装をした後にテストを実行すると、どこかで下記のようなエラーが発生するかもしれない。
FAIL src/infrastructure/dbconnection.spec.ts
● Test suite failed to run
Cannot find module 'src/infrastructure/dbconnection' from 'dbconnection.spec.ts'
1 | import { Connection } from 'typeorm';
> 2 | import { DBConnection } from 'src/infrastructure/dbconnection';
| ^
3 |
4 | describe('DBConnection', () => {
5 | let connection: Connection;
at Resolver.resolveModule (../node_modules/jest-resolve/build/index.js:299:11)
at Object.<anonymous> (infrastructure/dbconnection.spec.ts:2:1)
その場合は、 次の操作を行う
-
package.json
の編集
{
"name": "hoge",
...
"jest": {
"rootDir": ".",
"moduleNameMapper": {
"src(.*)$": "<rootDir>/src/$1"
}
}
}
-
test/jest-e2e.json
の編集
{
"moduleFileExtensions": ["js", "json", "ts"],
...
"rootDir": ".",
"moduleNameMapper": {
"src(.*)$": "<rootDir>/../src/$1"
},
}
これでテストが問題なく走るようになったはず。
DAO の作成
DB からデータを取ってくる部分は DAO(DataAccessObject) で
src/dao/users.dao.ts
import { EntityManager } from 'typeorm';
import { InternalServerErrorException } from '@nestjs/common';
export class UsersDao {
constructor(private readonly manager: EntityManager) {}
create = (name: string): Promise<unknown> =>
this.manager.query(`INSERT INTO "users" ("name") VALUES ($1);`, [name]);
findAll = async (): Promise<UsersDao.Entity[]> => {
const users: unknown[] = await this.manager.query(`
SELECT
id,
name
FROM
"users" AS users;
`);
return users.map(user => UsersDao.buildEntity(user));
};
findOneById = async (id: string): Promise<UsersDao.Entity | undefined> => {
const user: unknown[] = await this.manager.query(
`
SELECT
users. id,
users. name
FROM
"users"
WHERE
users. "id" = $1;
`,
[id],
);
if (user.length < 1) return undefined;
return UsersDao.buildEntity(user[0]);
};
private static buildEntity(object: unknown): UsersDao.Entity {
if (!UsersDao.isEntity(object))
throw new InternalServerErrorException(
object,
'fetched data type is invalid',
);
return {
id: object.id,
name: object.name,
};
}
}
export namespace UsersDao {
export interface Entity {
id: number;
name: string;
}
export const isEntity = (value: unknown): value is Entity => {
return (
typeof value === 'object' &&
typeof (value as Entity).id === 'number' &&
typeof (value as Entity).name === 'string'
);
};
}
テスト
users.dao.spec.ts
import { DBConnection } from 'src/infrastructure/dbconnection';
import { UsersDao } from './users.dao';
describe('UsersDao', () => {
let dao: UsersDao;
beforeEach(async () => {
dao = new UsersDao(await DBConnection.getManager());
});
it('should be defined', () => {
expect(dao).toBeDefined();
});
it('create', async () => {
const result = await dao.create('JohnDoe');
});
it('get', async () => {
const users = await dao.findAll();
expect(users).toBeDefined();
if (users.length < 1) return;
expect(users[0].id).toBeGreaterThan(0);
expect(users[0].name.length).toBeGreaterThan(0);
});
it('getById', async () => {
const id = '1';
const device = await dao.findOneById(id);
if (device) {
expect(device.id).toBeGreaterThan(0);
expect(device.name.length).toBeGreaterThan(0);
}
});
afterEach(async () => {
await DBConnection.close();
});
});
サービスの実装
動作のひとかたまりをサービスに置いて、コントローラから呼び出す
src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { DBConnection } from 'src/infrastructure/dbconnection';
import { UsersDao } from 'src/dao/users.dao';
@Injectable()
export class UsersService {
createFullName = (params: {
firstName?: string;
lastName?: string;
}): string => `${params.firstName}${params.lastName}`;
createUser = async (params: {
firstName?: string;
lastName?: string;
}): Promise<unknown> =>
(await DBConnection.getManager())
.transaction(manager =>
new UsersDao(manager).create(
this.createFullName({
firstName: params.firstName,
lastName: params.lastName,
}),
),
)
.finally(() => DBConnection.close());
getAllUser = async (): Promise<UsersDao.Entity[]> =>
(await DBConnection.getManager())
.transaction(manager => new UsersDao(manager).findAll())
.finally(() => DBConnection.close());
}
テスト
users.service.spec.ts
import { DBConnection } from 'src/infrastructure/dbconnection';
import { UsersDao } from './users.dao';
describe('UsersDao', () => {
let dao: UsersDao;
beforeEach(async () => {
dao = new UsersDao(await DBConnection.getManager());
});
it('should be defined', () => {
expect(dao).toBeDefined();
});
it('create', async () => {
const result = await dao.create('JohnDoe');
expect(result).toStrictEqual([]);
});
it('get', async () => {
const users = await dao.findAll();
expect(users).toBeDefined();
if (users.length < 1) return;
expect(users[0].id).toBeGreaterThan(0);
expect(users[0].name.length).toBeGreaterThan(0);
});
it('getById', async () => {
const id = '1';
const device = await dao.findOneById(id);
if (device) {
expect(device.id).toBeGreaterThan(0);
expect(device.name.length).toBeGreaterThan(0);
}
});
afterEach(async () => {
await DBConnection.close();
});
});
コントローラの作成
API の呼び出しはここが起点になる
src/users/users.controller.ts
import { Controller, Get, Query, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}
@Post()
async createUser(
@Body() createFullNameDto: { first_name?: string; last_name?: string },
) {
await this.usersService.createUser({
firstName: createFullNameDto.first_name,
lastName: createFullNameDto.last_name,
});
return {
method: 'post',
message: 'created',
};
}
@Get()
async getAllUsers() {
return {
users: await this.usersService.getAllUser(),
};
}
}
テスト
users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('Users Controller', () => {
let controller: UsersController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService],
controllers: [UsersController],
}).compile();
controller = module.get<UsersController>(UsersController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('create user', async () => {
const first = 'John';
const last = 'Doe';
const result = await controller.createUser({
first_name: first,
last_name: last,
});
expect(result.message).toBe(`created`);
});
it('get all users', async () => {
const result = await controller.getAllUsers();
expect(result.users.length).toBeGreaterThan(0);
expect(result.users[0].id).toBeGreaterThan(0);
expect(result.users[0].name.length).toBeGreaterThan(0);
});
});
統合テストの追加
test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from 'src/app.module';
import { UsersDao } from 'src/dao/users.dao';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
it('/users (POST)', () => {
return request(app.getHttpServer())
.post('/users')
.send({ first_name: 'John', last_name: 'Doe' })
.expect(201)
.expect(JSON.stringify({ method: 'post', message: 'created' }));
});
it('/users (GET)', async () => {
const response = await request(app.getHttpServer())
.get('/users?first_name=John&last_name=Doe')
.expect(200);
const body = response.body as {
users: UsersDao.Entity[];
};
expect(body.users).toBeDefined();
if (body.users.length < 1) return;
expect(body.users[0].id).toBeGreaterThan(0);
expect(body.users[0].name.length).toBeGreaterThan(0);
});
});
API にアクセスしてみる
- NestJS サーバーの起動
$ yarn start
- Docker コンテナ外からアクセス
$ curl -X GET "http://{DockerIP}:3000/users" -H "accept: */*"
{"users":[{"id":1,"name":"JohnDoe"}]}
この手順 (NestJS でプロジェクト作成から API ドキュメントの表示まで ) でAPIドキュメント表示用の設定をしておけば、 http://{DockerIP}:3000/api
にアクセスすることで、自由にAPIを試すことができる。