2017/05/12

TypeScript + express-openapiでOpenAPI(Swagger)準拠のRESTful APIをつくる

仕事でOpen API(旧Swagger)について調べる機会があった。せっかくなのでNode.jsでOpen APIに準拠したRESTful APIをつくろうと思う。

今回は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に貼り付けると、どんな仕様かわかりやすいかも)
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配下に配置する実装するファイルに直接記述する。
/* 略 */
basePath: '/v1'
/* 略 */
paths: {}
/* 略 */




コア部分を実装する

次にapi.ymlを読み込んで、expressを起動してAPIサーバを立ち上げるためのコア部分を実装する。メインはserver.tsで、エントリーポイントがindex.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指定なしのエンドポイントを実装する。
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のように指定する。
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を使っている。
その詳細については、以下の記事を参照ください。


参考サイト





以上

written by @bc_rikko

0 件のコメント :

コメントを投稿