TypeScript & Express で記述した Node.js 製 API サーバーのひな形です。
- 言語: TypeScript
- Node: Node.js (8 系: LTS)
- ルーティング: Express
- 認証: JWT 認証
- ORM: sequelize
- テスト: Mocha + power-assert
- クラスはほとんど使わず、ほぼ関数で記述しています。
- テストは単一のディレクトリ以下に配置するのではなく、それぞれのテスト対象と同じディレクトリに格納するという方式を取っています。
- Node.js のコールバックは使わず、Promise と Async Await を使用しています。
- Node.js (Version 8 LTS)
- Yarn
- direnv
- MySQL
- config : アプリケーションの設定を格納します。ex. JWT トークンのアルゴリズムやデータベースの設定など
- controllers : ユーザーのリクエストをハンドルする関数を格納します。
- errors : アプリケーション上で発生する各種エラーを格納します。
- helpers : アプリケーション上のあらゆる箇所で使用される関数群を格納します。production 環境であるかのチェックなど
- middlewares : Express ミドルウェアを格納します。例えば、ユーザーが認証済みでないとアクセスできない API に対して事前に認証チェックを施すミドルウェアなどを格納します。
- models : User や Book などのモデルを格納します。
- scripts : マイグレーションやシードデータの流し込みなど、yarn コマンドから呼び出されるスクリプト群を記述します。
- spec : モデルのファクトリ関数などテスト全般で使用するようなツール群を格納します。
# リポジトリをクローンします。
git clone [email protected]:AtaruOhto/node-api-server-starter.git
cd node-api-server-starter
# npmモジュールのインストール
yarn
# 環境変数ファイルをコピーします。
cp .envrc.sample .envrc
secret を生成して、「.envrc」にコピーします。 出力された文字列、下記の「export SECRET_KEY_BASE=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx」の部分を 「.envrc」に追記します。
yarn run secret
# 下記出力を .envrc に追記します。
# export SECRET_KEY_BASE=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
デフォルトでユーザー名は root , パスワードは pass , ホストは localhost ポート番号は 3306 番ポート に設定しています。変更する場合には .envrc を編集してください。DB_USER, DB_PASS, DB_PORT, DB_HOST を環境に従って編集します。direnv を使わない場合には OS の環境変数に設定してください。
export DB_USER='root'
export DB_PASS='pass'
export DB_HOST='127.0.0.1'
export DB_PORT=3306
データベースをデフォルトの MySQL から変更するには src/config/dsatabase.ts のdialectを編集します。
export const DB_CONFIG = {
...
dialect: 'mysql',
...
};
環境変数を編集して、データベースに接続できるように .envrc を編集したら、下記のコマンドを打って環境変数をロードします。
direnv allow
次にデータベースを作成、マイグレーションを行い、シードを流し込みます。
yarn run db:create
yarn run db:migrate
yarn run db:seed
サーバーを起動します。デフォルトでは 3000 番ポート で起動します。
yarn start
curl コマンドでアプリに向けて、JWT トークンを発行するようにリクエストします。返ってきた値が API を叩くために必要になる秘密のトークンです。
curl -X POST http://localhost:3000/sessions --data 'name=Erich&password=password'
下記のようなデータが返ってきます。data の部分 (jwt トークン) はそれぞれ異なった値が返ってきます。
{
"data":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJoYXNoIjp7Im5hbWUiOiJFcmljaCJ9LCJpYXQiOjE1MzUyMDUzMDIsImV4cCI6MTUzNTI5MTcwMn0.DRCHA1qRwrmpBscw_ZFAde6tBPJEb7IiCso9-mXG2Gk",
"status":200
}
認証が求められるユーザー一覧取得の API を叩いてみます。 「Bearer の後、半角スペースを一つ空けて」実際に返ってきたdata の部分の JWT トークンをサーバー側に送ります。
curl -X GET http://localhost:3000/users -H "X-Auth-Token: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJoYXNoIjp7Im5hbWUiOiJFcmljaCJ9LCJpYXQiOjE1MzUyMDUzMDIsImV4cCI6MTUzNTI5MTcwMn0.DRCHA1qRwrmpBscw_ZFAde6tBPJEb7IiCso9-mXG2Gk"
シードで流したユーザー一覧が取得できます。
{
"data":
[
{"id":9,"name":"Erich","password":"$2b$10$5oo2y/pqQ.NTcaQgL4DF3ODlM3DKDsyiQZgnu5seQS/vUN1lkI8ua"},
{"id":10,"name":"Richard","password":"$2b$10$5oo2y/pqQ.NTcaQgL4DF3ODlM3DKDsyiQZgnu5seQS/vUN1lkI8ua"},
{"id":11,"name":"Ralph","password":"$2b$10$5oo2y/pqQ.NTcaQgL4DF3ODlM3DKDsyiQZgnu5seQS/vUN1lkI8ua"},
{"id":12,"name":"John","password":"$2b$10$5oo2y/pqQ.NTcaQgL4DF3ODlM3DKDsyiQZgnu5seQS/vUN1lkI8ua"}
]
,"status":200
}
間違ったトークンや不正なリクエストを送ると下記のようなレスポンスが返ってきます。試しにトークンから一文字削除して、誤ったトークンの値を送ってみます。ステータスが 400 のレスポンスが返ってきます。
{
"data":{},
"status":400
}
下記のコマンドでテストが走ります。テストファイルはテスト対象のファイルと同じディレクトリに格納する形式をとっています。
# テストを実行する前にデータベース、テーブル等を作成
yarn run db:create:test
yarn run db:migrate:test
yarn run db:seed:test
yarn run test
新しいモデルを追加して、それに対応するマイグレーションやコントローラーを記述することを通して 一連の開発方法例を提示します。
ターミナルで下記コマンドをそれぞれ別タブで起動します。
# npmモジュールのインストール
yarn
# TypeScriptのwatchビルド
yarn run watch
# ソースコード変更を検知してNodeサーバーを自動的に再起動
yarn run dev
TypeScript のコンパイルエラーがあれば、ターミナル上で通知されます。
src/models/framework/index.ts ファイルを追加します。
モデルは sequelizeの記法に従っています。 sequelize についてはsequelizeこちらをご覧ください。
/* src/models/framework/index.ts */
import Sequelize from 'sequelize';
import { sequelizeInstance } from 'config/database';
export const FrameworksTable = {
name: 'frameworks',
schema: {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER,
},
name: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
},
language: {
allowNull: false,
type: Sequelize.STRING,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
},
},
};
export const Framework = sequelizeInstance.define(
FrameworksTable.name,
FrameworksTable.schema,
);
src/scripts/migrations/createFrameworks.ts を追加します。 sequelize/cli を使う方法もあるのですが、より柔軟性の高い、スクリプトで記述することにしています。
/* src/scripts/migrations/createFrameworks.ts */
import { Framework } from 'models/framework';
export const createFrameworkMigrate = () =>
new Promise(async (resolve, reject) => {
try {
await Framework.sync();
resolve();
} catch (e) {
reject(e);
}
});
src/scripts/migrations.ts から呼び出しを追加します。
/* src/scripts/migrations.ts */
import { createFrameworkMigrate } from './migrations/createFrameworks';
(async () => {
...
/* 追加 */
await createFrameworkMigrate();
...
sequelizeInstance.close();
})();
src/scripts/seeds/frameworks.ts にシードデータの追加処理を記述します。
/* src/scripts/seeds/frameworks.ts */
import { Framework } from 'models/framework';
export const seedFrameworks = () =>
new Promise(async (resolve, reject) => {
await Framework.bulkCreate([
{
name: 'Express',
language: 'JavaScript',
},
{
name: 'Ruby on Rails',
language: 'Ruby',
},
{
name: 'Django',
language: 'Python',
},
{
name: 'Laravel',
language: 'PHP',
},
]).catch(e => {
/* Errorを出力する。 */
console.log(e);
});
resolve();
});
上記で実装した追加処理の呼び出しを記述します。
/* src/scripts/seeds.ts */
import { seedFrameworks } from './seeds/frameworks';
(async () => {
...
await seedFrameworks();
sequelizeInstance.close();
})();
下記コマンドを打って、データベースを作成、マイグレーション、シードデータを流し込みます。
yarn run db:create
yarn run db:migrate
yarn run db:seed
src/spec/factories/frameworkFactory.ts を作成します。
テストフレームワークはMocha, アサーションライブラリーとしてpower-assertを使っています。
/* src/spec/factories/frameworkFactory.ts */
import { Framework } from 'models/framework';
export const TEST_FRAMEWORK = 'GreatFramework';
export const TEST_LANGUAGE = 'whiteSpace';
export const destroyTestFramework = () =>
new Promise(async resolve => {
await Framework.destroy({
where: {
name: TEST_FRAMEWORK,
},
});
resolve();
});
export const findOrCreateTestFramework = (otherAttrs: any) =>
new Promise(async resolve => {
const instance = await Framework.findOrCreate({
where: {
name: TEST_FRAMEWORK,
language: TEST_LANGUAGE,
},
defaults: otherAttrs,
});
resolve(instance);
});
モデルのテストを記述します。
src/models/framework/spec.ts を記述します。
- テストファイルはテスト対象のファイルと同じディレクトリに配置します。
import { Framework } from 'models/framework';
import assert from 'power-assert';
import {
destroyTestFramework,
findOrCreateTestFramework,
TEST_FRAMEWORK,
} from 'spec/factories/frameworkFactory';
describe('Framework', () => {
describe('Positive', () => {
beforeEach(() =>
new Promise(async resolve => {
await findOrCreateTestFramework({});
resolve();
}));
afterEach(() =>
new Promise(async resolve => {
await destroyTestFramework();
resolve();
}));
it('success', () =>
new Promise(async (resolve, reject) => {
const framework = (await Framework.findOne({
where: { name: TEST_FRAMEWORK },
})) as any;
assert.equal(framework.name, TEST_FRAMEWORK);
resolve();
}));
});
describe('Negative', () => {
it('fail without language', () =>
new Promise(async (resolve, reject) => {
try {
await Framework.create({
name: 'foobarFramework',
});
} catch (e) {
resolve();
}
}));
});
});
テストを走らせてみます。
yarn run db:create:test
yarn run db:migrate:test
yarn run db:seed:test
yarn run test
正常に Framework モデルが作成できることと、NOTNULL 制約がかかっている _language* を欠いた Frmework モデルを create しようとすると例外が起きることをチェックしています。
framework をすべて取得するアクション (frameworksIndex) を定義します。 / src/controllers/api/v1/frameworks.ts / を追加します。 ルーティングの命名や形式は Rails に従っています。
引用元 https://railsguides.jp/routing.html
HTTP動詞 | パス | コントローラ#アクション | 目的 |
---|---|---|---|
GET | /photos | photos#index | すべての写真の一覧を表示 |
GET | /photos/new | photos#new | 写真を1つ作成するためのHTMLフォームを返す |
POST | /photos | photos#create | 写真を1つ作成する |
GET | /photos/:id | photos#show | 特定の写真を表示する |
GET | /photos/:id/edit | photos#edit | 写真編集用のHTMLフォームを1つ返す |
PATCH/PUT | /photos/:id | photos#update | 特定の写真を更新する |
DELETE | /photos/:id | photos#destroy | 特定の写真を削除する |
import { Request, Response } from 'express';
import { respondWith } from 'helpers/response';
import { Framework } from 'models/framework';
export const frameworksIndex = async (req: Request, res: Response) => {
try {
const frameworks = await Framework.findAll();
respondWith(res, 200, frameworks);
} catch (e) {
respondWith(res, 500);
}
};
src/config/path.ts にパスを追加します。 アプリケーション上で参照されるパスはすべてこのファイルに記述するようにしています。
/* src/config/path.ts */
export const path = {
...
/* 追加 */
frameworks: '/frameworks/'
};
config/routes.ts のdefineRoutes() にルート定義を追加します。 アプリケーション上で参照されるルーティングとハンドラーの組み合わせはすべてこのファイルに記述するようにしています。
import { frameworksIndex } from 'controllers/api/v1/frameworks';
export const defineRoutes = (app: Express) => {
...
/* 追加 */
app.get(path.frameworks, frameworksIndex);
...
};
認証済みのユーザーに対してしか、コンテンツを見せたくない場合には requireAuth() ミドルウェア関数を frameworksIndex の前に適用します。
それぞれ Terminal の別ウィンドウで実行します。
curl コマンド を使って定義したルーティングを叩いてみます。
curl -X GET http://localhost:3000/frameworks
すると下記のようにシードで流し込んだフレームワーク一覧の JSON データが返ってきます。
{"data":
[
{"id":1,"name":"Express","language":"JavaScript"},
{"id":2,"name":"Ruby on Rails","language":"Ruby"},
{"id":3,"name":"Django","language":"Python"},
{"id":4,"name":"Laravel","language":"PHP"}
],"
status":200
}
- src/controllers/api/v1/frameworks/spec.ts
/* src/controllers/api/v1/frameworks/spec.ts */
import assert from 'power-assert';
import request from 'supertest';
import { path } from 'config/path';
import { app } from 'index';
import {
destroyTestFramework,
findOrCreateTestFramework,
TEST_FRAMEWORK,
} from 'spec/factories/frameworkFactory';
describe(`Framework Controller`, () => {
beforeEach(() =>
new Promise(async resolve => {
await findOrCreateTestFramework({});
resolve();
}));
afterEach(() =>
new Promise(async resolve => {
await destroyTestFramework();
resolve();
}));
describe('Create', () => {
describe(`Positive`, () =>
it('User will be successfully created', () =>
new Promise(resolve => {
request(app)
.get(path.frameworks)
.set('Accept', 'application/json')
.then(async (res: any) => {
const framework = res.body.data.filter(
(elem: any) => elem.name === TEST_FRAMEWORK,
);
assert.equal(framework[0].name, TEST_FRAMEWORK);
resolve();
});
})));
});
});
下記コマンドでテストを走らせることができます。
yarn run test