今回はexpress-openapiというフレームワークを使う。Express.jsを拡張し、スキーマのバリデーションをしてくれたり、コードを管理しやすくしてくれたりする。また、標準でTypeScriptをサポートしているので、チーム開発にも向いている。
使う環境やフレームワークは以下のとおり。
- Node.js: 6.10.2
- TypeScript: 2.2.2
- express: 4.15.2
- express-openapi: 0.35.0
Open API とは
REST APIやSOAP、Open API、JSON API、など似たような名前がいっぱいある。
Open APIは、OpenAPI Specificationの略で使われることが多い。
REST APIをつくるために、インターフェースのフォーマットを標準化しましょう。そうすればドキュメントやテストケース、コードの自動生成などができ、生産性があがりますよー。みたいな感じでSwaggerが生まれた。
それをMicrosoftやGoogle、IBM、PayPalなどが中心となり「Open API Initiative」という団体をつくり、それに伴い名前がSwaggerからOpen APIにかわった。
Open API Specification とは
説明するとそれだけで1記事かけてしまうので、詳細は省略する。
公式ドキュメントを読んでいただいたほうが早いだろう。
Open API Specificationのバージョンは主流はv2.0なのだが、v3.0も公開されている。
今回は、一般的によく使われているv2.0で話をすすめる。
APIを設計する
定番のTodoアプリをつくるため、まずはアプリケーションのインターフェース(API)を設計する。
Open APIで書くには、Swagger Editorというツールを使うのが便利。
Swagger Editorを使う
Swagger Editorを使うと、上図のように右ペインにAPIの情報が表示され、実行することもできる。
パブリックなWebサービスなので、API仕様を入力するのはちょっと…。
という方には、Dockerを使うと簡単にプライベートな環境でSwagger Editorを使うことができる。(もうDockerじゃなくてMobyなんだっけ?)
Dockerはインストールしてあることを前提とする。
以下のコマンドを実行すると、http://localhostでSwagger Editorを使うことができる。
$ docker run -d -p 80:8080 swaggerapi/swagger-editor
APIの仕様
Todoアプリを作りたいので、以下のエンドポイントを用意する。
- GET /tasks: タスク一覧を取得する
- POST /tasks: タスクを登録する
- GET /tasks/{id}: 指定IDのタスクを取得する
- PUT /tasks/{id}: 指定IDのタスクを更新する
- DELETE /tasks/{id}: 指定IDのタスクを削除する
- GET /schema: APIのスキーマ情報を取得する
パラメータは、以下のとおり。
- query
- offset: 一覧取得時の開始位置
- limit: 一覧取得時の上限数
- body
- title: タスクのタイトル
- is_done: タスクの状態
その他スキーマの詳細は、以下のyamlを参照ください。
(コピーしてSwagger Editorに貼り付けると、どんな仕様かわかりやすいかも)
# api.yml
swagger: '2.0'
basePath: '/v1'
info:
version: "1.0"
title: Todo Application API
schemes:
- http
consumes:
- application/json
produces:
- application/json
paths:
/tasks:
get:
summary: タスク一覧の取得
description: |
タスク一覧を取得します
operationId: getTaskList
parameters:
- $ref: '#/parameters/offset'
- $ref: '#/parameters/limit'
responses:
200:
description: タスク一覧を取得しました
schema:
allOf:
- $ref: '#/definitions/TaskList'
- $ref: '#/definitions/PageInfo'
404:
description: タスク一覧が取得できませんでした
schema:
$ref: '#/definitions/Error'
default:
description: 予期しないエラー
schema:
$ref: '#/definitions/Error'
post:
summary: タスクの登録
description: |
タスクを登録します
operationId: createTask
parameters:
-
name: task
in: body
schema:
$ref: '#/definitions/TaskToPost'
responses:
201:
description: タスクを登録しました
schema:
$ref: '#/definitions/TaskOne'
400:
description: タスクが登録できませんでした
schema:
$ref: '#/definitions/Error'
default:
description: 予期しないエラー
schema:
$ref: '#/definitions/Error'
/tasks/{id}:
get:
summary: 指定IDタスクの取得
description: |
パスに指定されたIDのタスクを取得します
parameters:
- $ref: '#/parameters/id'
responses:
200:
description: タスクを取得しました
schema:
$ref: '#/definitions/TaskOne'
404:
description: 指定IDのタスクが見つかりませんでした
schema:
$ref: '#/definitions/Error'
default:
description: 予期しないエラー
schema:
$ref: '#/definitions/Error'
put:
summary: 指定IDタスクの更新
description: |
パスに指定されたIDのタスクを更新します
parameters:
- $ref: '#/parameters/id'
-
name: task
in: body
schema:
$ref: '#/definitions/TaskToPut'
responses:
200:
description: タスクを更新しました
schema:
$ref: '#/definitions/TaskOne'
400:
description: タスクが更新できませんでした
schema:
$ref: '#/definitions/Error'
default:
description: 予期しないエラー
schema:
$ref: '#/definitions/Error'
delete:
summary: 指定IDタスクの削除
description: |
パスに指定されたIDのタスクを削除します
parameters:
- $ref: '#/parameters/id'
responses:
200:
description: タスクを削除しました
400:
description: タスクが削除できませんでした
schema:
$ref: '#/definitions/Error'
default:
description: 予期しないエラー
schema:
$ref: '#/definitions/Error'
parameters:
id:
description: タスクID
name: id
in: path
required: true
type: integer
format: int32
offset:
description: 取得するレコードの開始位置
name: offset
in: query
type: integer
format: int32
minimum: 0
limit:
description: 取得するレコードの件数
name: limit
in: query
type: integer
format: int32
minimum: 1
definitions:
Error:
type: object
readOnly: true
properties:
code:
description: HTTPステータスコード
type: integer
format: int32
message:
description: エラーメッセージ
type: string
PageInfo:
type: object
readOnly: true
required:
- total
- offset
properties:
total:
description: 取得件数の上限数
type: integer
format: int32
minimum: 0
offset:
description: 取得したレコードの開始位置
type: integer
format: int32
minimum: 0
Task:
type: object
readOnly: true
properties:
id:
description: タスクのID
type: integer
format: int32
title:
description: タスクの名前
type: string
is_done:
description: タスクの状態
default: false
type: boolean
TaskList:
type: object
readOnly: true
properties:
tasks:
type: array
items:
$ref: '#/definitions/Task'
TaskOne:
type: object
readOnly: true
properties:
task:
$ref: '#/definitions/Task'
TaskToPost:
type: object
required:
- title
properties:
title:
description: タスクの名前
type: string
minLength: 1
maxLength: 64
is_done:
description: タスクの状態
default: false
type: boolean
TaskToPut:
type: object
properties:
title:
description: タスクの名前
type: string
minLength: 1
maxLength: 64
is_done:
description: タスクの状態
type: boolean
ライブラリをインストールする
API開発に必要なライブラリをインストールする。
- express: Webアプリケーションフレームワーク
- express-openapi: expressを拡張するフレームワーク
- body-parser: リクエストのbodyを解析するミドルウェア
- js-yaml: YAMLを読み込むツール
- (任意)typeorm: TypeScriptで書かれたORM
- (任意)sqlite3: データベース操作用
$ npm i -S express @type/express
$ npm i -S express-openapi
$ npm i -S body-parser @type/body-parser
$ npm i -S js-yaml @type/js-yaml
$ npm i -S typeorm sqlite3
TypeScriptをビルドするためのgulpfileは、GitHubのリポジトリを参照ください。
Scaffolding
ディレクトリ構成は以下のとおり。
. ├── package.json ├── api.yml ├── api_original.yml ├── node_modules ├── server ├── src │ ├── index.ts │ ├── server.ts │ ├── api.ts │ ├── api │ │ ├── tasks │ │ │ └── {id}.ts │ │ └── tasks.ts │ ├── models │ │ ├── Task.ts │ │ └── TaskController.ts │ └── store │ └── index.ts ├── test │ └── test.js ├── gulpfile.js └── tsconfig.json
- package.json
- api.yml: express-openapi用に編集したapi_original.yml
- api_original.yml: Open APIで書いたスキーマ
- /server: ビルド先
- /src
- index.ts: エントリーポイント
- server.ts: コア部分
- api.ts: レスポンスの共通処理
- /api: エンドポイント
- /tasks
- {id}.ts: basePath/tasks/{id}
- tasks.ts: basePath/tasks
- /models
- Task.ts: テーブル定義
- TaskController.ts: Taskテーブルを操作する
- /store
- index.ts: データベースの設定
- /test
- test.js: テストコード
- gulpfile.js
- tsconfig.json
api.ymlを編集する
express-openapiでは、Swagger Editorなどで書いたスキーマの定義をそのまま使わないので、api.ymlを以下のように編集する。
basePathはAPIのパスになる。以下の場合は、http://example.com/v1/…といった感じだ。
pathsは空のオブジェクトを定義するだけで、詳細は/api配下に配置する実装するファイルに直接記述する。
# api.yml
/* 略 */
basePath: '/v1'
/* 略 */
paths: {}
/* 略 */
コア部分を実装する
次にapi.ymlを読み込んで、expressを起動してAPIサーバを立ち上げるためのコア部分を実装する。メインはserver.tsで、エントリーポイントがindex.tsになる。// Server.ts
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import * as express from 'express';
import * as openapi from 'express-openapi';
import * as bodyParser from 'body-parser';
class Server {
port: number = process.env.PORT || 10080;
app = express();
constructor () {
// api.ymlを読み込んでJSONに変換する
const api = yaml.safeLoad(fs.readFileSync('api.yml', 'utf-8'));
// express-openapiの初期化
openapi.initialize({
app: this.app,
apiDoc: api,
// エンドポイントを実装するディレクトリ
paths: './server/api',
// Content-Typeの指定
consumesMiddleware: {
'application/json': bodyParser.json(),
'text/text': bodyParser.text()
},
// express-openapiが標準でやってくれるバリデーション結果のレスポンス処理
errorMiddleware: (err, req, res, next) => {
res.status(400);
res.json(err);
},
errorTransformer: (openapi, jsonschema) => {
return openapi.message;
},
// exposeApiDocsをtrueにすることでGET /schemaで完全版のスキーマが取得できる
docsPath: '/schema',
exposeApiDocs: true
});
}
start () {
this.app.listen(this.port, () => {
console.log(`listening on ${this.port}`);
});
}
}
export default Server;
import Server from './Server'; const server = new Server(); server.start();
Server.tsで初期化をしている。index.tsはServer.start()を呼び出しているだけ。
エンドポイントを実装する
express-openapiのinitializeのargs.paths配下が、そのままエンドポイントに対応づく。たとえば、./api/tasks.tsならGET /tasks、./api/tasks/{id}.tsならGET /tasks/{id}といった具合だ。
GET /tasks、POST /tasksを実装する
まずはID指定なしのエンドポイントを実装する。// ./api/tasks.ts
import { Operation } from 'express-openapi';
import * as api from '../api';
import Task from '../models/TaskController';
import { ITaskListResponse, ITaskList, ITaskOne } from '../models/TaskController';
// GET /tasksが呼ばれたときの処理
export const get: Operation = async (req, res) => {
let tasks: ITaskListResponse;
try {
tasks = await Task.all(req.query);
} catch (err) {
api.responseError(res, err);
}
api.responseJSON(res, 200, tasks);
};
// スキーマ定義のGET /tasksの部分だけをココに定義する
get.apiDoc = {
summary: 'タスク一覧の取得',
description: 'タスク一覧を取得します',
operationId: 'getTasks',
parameters: [
{ $ref: '#/parameters/offset' },
{ $ref: '#/parameters/limit' }
],
responses: {
200: {
description: 'タスク一覧を取得しました',
schema: {
type: 'array',
items: {
$ref: '#/definitions/TaskList'
}
}
},
404: {
description: 'タスク一覧が取得できませんでした',
schema: {
$ref: '#/definitions/Error'
}
},
default: {
description: '予期しないエラー',
schema: {
$ref: '#/definitions/Error'
}
}
}
};
// POST /tasksが呼ばれたときの処理
export const post: Operation = async (req, res) => {
let task: ITaskOne;
try {
task = await Task.add(req.body);
} catch (err) {
api.responseError(res, err);
}
api.responseJSON(res, 201, task);
};
// スキーマ定義のPOST /tasksの部分だけをココに定義する
post.apiDoc = {
summary: 'タスクの登録',
description: 'タスクを登録します',
operationId: 'postTask',
parameters: [
{
name: 'task',
in: 'body',
schema: {
$ref: '#/definitions/TaskToPost'
}
}
],
responses: {
201: {
description: 'タスクを登録しました',
schema: {
$ref: '#/definitions/TaskOne'
}
},
400: {
description: 'タスクが登録できませんでした',
schema: {
$ref: '#/definitions/Error'
}
},
default: {
description: '予期しないエラー',
schema: {
$ref: '#/definitions/Error'
}
}
}
};
ディレクトリ+ファイル名と、exportしているget、postにより、GET /tasksとPOST /tasksがひも付き、APIが呼ばれたときに実行される。
ちなみにparametersで指定している部分は、express-openapiが標準でバリデーションをしてくれるので、ユーザがパラメータチェックをする必要はない。
importしているTaskControllerやapiについては、GitHubのリポジトリを確認してほしい。やっていることは、データベースへのアクセスやAPIレスポンスの処理だ。
GET /tasks/{id}、PUT /tasks/{id}、DELETE /tasks/{id}を実装する
次にID指定ありのエンドポイントを実装する。何度も述べているが、ディレクトリ+ファイル名がエンドポイントと紐づくので、これらの処理は「./api/tasks/{id}.ts」というファイルに実装する。ID部分は中括弧(mustache)を使って{id}.tsや{hoge}.tsのように指定する。
// /api/tasks/{id}.ts
import { Operation } from 'express-openapi';
import * as api from '../../api';
import Task from '../../models/TaskController';
import { ITaskOne } from '../../models/TaskController';
// GET /tasks/{id}が呼ばれたときの処理
export const get: Operation = async (req, res) => {
let task: ITaskOne;
try {
task = await Task.get(req.params.id);
} catch (err) {
api.responseError(res, err);
}
api.responseJSON(res, 200, task);
};
// スキーマ定義のGET /tasks/{id}の部分だけをココに定義する
get.apiDoc = {
summary: '指定IDタスクの取得',
description: 'パスに指定されたIDのタスクを取得します',
operationId: 'getTaskById',
parameters: [
{
$ref: '#/parameters/id'
}
],
responses: {
200: {
description: 'タスクを取得しました',
schema: {
$ref: '#/definitions/TaskOne'
}
},
404: {
description: '指定IDのタスクが見つかりませんでした',
schema: {
$ref: '#/definitions/Error'
}
},
default: {
description: '予期しないエラー',
schema: {
$ref: '#/definitions/Error'
}
}
}
};
// PUT /tasks/{id}が呼ばれたときの処理
export const put: Operation = async (req, res) => {
let task: ITaskOne;
try {
task = await Task.update(req.params.id, req.body);
} catch (err) {
api.responseError(res, err);
}
api.responseJSON(res, 200, task);
};
// スキーマ定義のPUT /tasks/{id}の部分だけをココに定義する
put.apiDoc = {
summary: '指定IDタスクの更新',
description: 'パスに指定されたIDのタスクを更新します',
operationId: 'updateTaskById',
parameters: [
{
$ref: '#/parameters/id'
},
{
name: 'task',
in: 'body',
schema: {
$ref: '#/definitions/TaskToPut'
}
}
],
responses: {
200: {
description: 'タスクを更新しました',
schema: {
$ref: '#/definitions/TaskOne'
}
},
404: {
description: '指定IDのタスクが見つかりませんでした',
schema: {
$ref: '#/definitions/Error'
}
},
default: {
description: '予期しないエラー',
schema: {
$ref: '#/definitions/Error'
}
}
}
};
// DELETE /tasks/{id}が呼ばれたときの処理
export const del: Operation = async (req, res) => {
let task: ITaskOne;
try {
task = await Task.delete(req.params.id);
} catch (err) {
api.responseError(res, err);
}
api.responseJSON(res, 200, task);
};
// スキーマ定義のDELETE /tasks/{id}の部分だけをココに定義する
del.apiDoc = {
summary: '指定IDタスクの削除',
description: 'パスに指定されたIDのタスクを削除します',
operationId: 'deleteTaskById',
parameters: [
{
$ref: '#/parameters/id'
}
],
responses: {
200: {
description: 'タスクを削除しました'
},
404: {
description: '指定IDのタスクが見つかりませんでした',
schema: {
$ref: '#/definitions/Error'
}
},
default: {
description: '予期しないエラー',
schema: {
$ref: '#/definitions/Error'
}
}
}
};
こちらもexportしているget、put、delがそれぞれGET /tasks/{id}、PUT /tasks/{id}、DELETE /tasks/{id}に対応している。
express-openapiを使ってみた感想
ディレクトリとファイル名がそのままエンドポイントと紐づくのはちょっと驚いたが、実際にメンテしてみると「どこになにが書かれているか」が一目瞭然で理解しやすかった。
スキーマの定義をルートとエンドポイントの各ファイルに分けて書くことに違和感があった。しかし、OpenAPIでスキーマ定義をしていくとyamlファイルが膨大になることを思えば、処理ごとにpathsをわけられるのでメンテナンスしやすい気がする。
また、パラメータのバリデーションはフレームワークは良い感じにやってくれるので、エンジニアはコアな部分の開発に集中できるのもメリットだろう。(プラグインがあるので他にもいろいろやってくれそう)
ただ、Node.jsなのでPMやforeverといったプロセスマネージャは必須だろう。
より細かな実装や、configの設定、gulpfileやテストコードは以下のリポジトリを参照ください。
データベースの管理にはtypeormというTypeScriptで書かれたORMを使っている。
その詳細については、以下の記事を参照ください。
参考サイト
- GitHub - kogosoftwarellc/express-openapi
- GitHub - Chinachu/Mirakurun
- Open API Initiative
- OpenAPI-Specification/2.0.md OAI/OpenAPI-Specification · GitHub
以上
written by @bc_rikko
This is very helpful, thank you!
返信削除このブロゴはいい日本語の練習!
Thank you for your comment!
削除I'm glad I could help.