🚅

express-openapiを試してみた

2022/04/07に公開

はじめに

エンジニアの籏野です。
フォルシアではOpenAPIでAPI定義を書いてから、APIを実装するのが一般的になってきました。
APIの実装についてはTypeScriptとexpressを利用することが増えてきている状況です。
今回はexpressとOpenAPIをより強固に結びつけるためのモジュールとしてexpress-openapiを見つけたので試してみました。

ざっくりまとめ

  • エンドポイントをOpenAPI定義に沿って作成してくれる
  • リクエストパラメータのバリデーションもお手の物
  • レスポンス項目もチェックしてくれる
  • 成果物

環境

Node.js: 16.13.2
TypeScript: 4.6.2
express: 4.17.3
express-openapi: 10.1.0

準備

npmでプロジェクトを作成し以下のモジュールをインストールします。
linter等はお好みで。

  • express
  • express-openapi
  • TypeScript
  • @types/express
  • ts-node

expressの起動

まずはシンプルにexpressサーバーを起動してみます。

import express, { Request, Response } from "express";

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.listen(3000, () => {
	console.log("Start on port 3000");
});

app.get("/user", (req: Request, res: Response) => {
	res.send({
		name: "hatano"
	});
});

ts-nodeを利用すれば、tsファイルのままで実行できるので便利ですね。

npx ts-node index.ts

http://localhost:3000/user にアクセスすることで以下の結果が取得できます。

{
    name: "hatano"
}

OpenAPI定義を作成

OpenAPIが何なのかという説明は長くなるので省略します。
定義書の作成については別の記事でも紹介していますので、よければご確認ください。
npmパッケージを組み合わせてSwaggerの定義ファイルをいい感じに書く

今回作成したAPIを定義書に落とすと以下のようになります。

{
  "openapi": "3.0.2",
  "info": {
    "title": "API定義書",
    "description": "express-openapiを試すための定義書",
    "version": "0.1.0"
  },
  "servers": [
    {
      "url": "http://localhost:3000"
    }
  ],
  "paths": {
    "/user": {
      "get": {
        "summary": "ユーザー取得API",
        "description": "ユーザー情報を取得するAPI",
        "operationId": "getUser",
        "parameters": [],
        "responses": {
          "200": {
            "description": "取得に成功した場合",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["name"],
                  "properties": {
                    "name": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

express-openapiの利用

ここからが本番です。
express-openapiを利用することでindex.tsは以下のように書き換わります。

import express, { Request, Response, NextFunction } from "express";
import { initialize } from "express-openapi";
import path from "path";

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.listen(3000, () => {
	console.log("Start on port 3000");
});

initialize({
	app: app,
	apiDoc: path.resolve(__dirname, "openapi.json"),
	validateApiDoc: true,
	operations: {
		getUser: [
			function (req: Request, res: Response, next: NextFunction) {
				next();
			},
			function (req: Request, res: Response) {
				res.send({
					name: "hatano"
				});
			}
		]
	}
});

initializeにオプションを与えるだけなので利用はとても簡単ですね。
それぞれのオプションについて解説します。

args.app (required)

express-openapiを適用したいexpressアプリケーションを指定します。

args.apiDoc (required)

読み込ませるAPI定義書を指定するためのオプションです。
渡せる値としては以下のいずれかになりますが、基本的には定義書のパスを渡せばよい気がします。

  • OpenAPI定義のjsonパス
  • fsなどを使ってjsonファイルを読み込んだ文字列
  • OpenAPI定義に沿ったオブジェクト

読み込んだ定義に何かしらの不備があるとエラーを出してくれるのも便利です。
OpenAPI定義のチェックを行わない場合は args.validateApiDoc をfalseにします。

express-openapi: validation errors [
  {
    "instancePath": "/servers",
    "schemaPath": "#/properties/servers/uniqueItems",
    "keyword": "uniqueItems",
    "params": {
      "i": 1,
      "j": 0
    },
    "message": "must NOT have duplicate items (items ## 0 and 1 are identical)"
  }
]
Error: express-openapi: args.apiDoc was invalid.  See the output.

args.operations

API実装の本体になります。
OpenAPI定義書に記載の operationIdをキーとして、APIを実装します。
値の部分には関数もしくは関数の配列を定義できます。
配列にした場合はミドルウェアとして定義され、NextFunctionを実行することで次の処理に伝播していきます。

既にお気づきだと思いますが、operationsを利用するとAPIエンドポイントパスを記載する必要がなく、OpenAPI定義に記載の通りのエンドポイントを勝手に生成してくれます。

リクエストパラメータのバリデーション

express-openapiを利用する大きなメリットとして、リクエストパラメータのバリデーションを自動で行ってくれます。
先ほどのAPI定義にパラメータ定義を追加してみましょう。

"parameters": [
  {
    "name": "user",
    "in": "query",
    "required": true,
    "schema": {
      "type": "string",
      "enum": ["hatano"]
    }
  }
],

さらにinitializeに渡すオプションにerrorMiddlewareを追加します。
こちらはAPIアクセス時に何かしらのエラーが発生した時に実行されるミドルウェアになります。
エラーハンドリングが1箇所にまとまるのはいい感じです。

{
	errorMiddleware: (
		err,
		req: Request,
		res: Response,
		_next: NextFunction
	) => {
		res.status(err.status || 500).json(err);
	}
}

この状態で http://localhost:3000/user にアクセスすると以下のエラーが返ります。

{
    status: 400,
    errors: [
        {
            path: "user",
            errorCode: "required.openapi.requestValidation",
            message: "must have required property 'user'",
            location: "query"
        }
    ]
}

必須のパラメータが付与されていないためのエラーになります。
今回はuserパラメータにenumを指定しているので、user=hogeのような値を指定した場合にもエラーとなります。

これまではリクエストパラメータのバリデーションを社内で独自に実装していましたが、express-openapiに大部分を任せられるようになるのはとてもありがたいです。

レスポンス項目のチェック

express-openapiではレスポンス項目のチェックにも対応しています。
以下のように実装することでエラーを取得できます。
リクエスト項目と異なり、エラーがthrowされることはないのでエラー検知時にどのようにハンドリングするかはアプリで検討する必要があります。

// express-openapiがResponseの型を提供していないようなので、エラーを回避するために項目追加
function (req: Request, res: Response & { validateResponse: any }) {
    const response = {
        name: 200 // string型想定のところにnumber型を入れるとエラーになる。
    };
    const validationError = res.validateResponse(200, response);
    if (validationError) {
        throw validationError;
    }
    res.status(200).send(response);
}
{
  message: 'The response was not valid.',
  errors: [
    {
      path: 'name',
      errorCode: 'type.openapi.responseValidation',
      message: 'must be string'
    }
  ]
}

OpenAPI定義にpathsを定義しない方法

ここまで試した方法は、すべての定義をOpenAPI定義書に記載するというものでした。
しかしexpress-openapiを利用していると、pathsの定義を記載せずにディレクトリ構成に従ってAPIを自動生成させることも可能です。
以下のようにファイルを作成し、APIを実装します。

paths
    └── user
        └── {id}.ts
import { Operation } from "express-openapi";

export const GET: Operation = [
	(req, res, _next) => {
		res.status(200).json({
			id: req.params.id
		});
	}
];

GET.apiDoc = {
	summary: "ユーザーID API",
	description: "ユーザーIDを取得する関数",
	operationId: "getUserId",
	parameters: [
		{
			in: "path",
			name: "id",
			required: true,
			schema: { type: "integer" }
		}
	],
	responses: {
		200: {
			description: "取得に成功した場合",
			content: {
				"application/json": {
					schema: {
						type: "object",
						required: ["id"],
						properties: {
							id: {
								type: "integer"
							}
						}
					}
				}
			}
		}
	}
};

また、initializeに渡すオプションに以下を追加します。

{
	paths: "./paths",
    // 以下はtsファイルでAPIを実装する場合に必要
	routesGlob: "**/*.{ts,js}",
	routesIndexFileRegExp: /(?:index)?\.[tj]s$/,
}

上記を作成することで、http://localhost:3000/user/{id} にAPIが作成されます。
※{id}には任意のintegerが入ります。
operationsでAPIを作成した時と同じくリクエストパラメータのバリデーション、レスポンス項目のチェックを行ってくれます。

pathsもしくはoperationsのどちらかが必須設定項目になっています。
どちらを利用するかはチームの要件に従えばよさそうです。

OpenAPI定義の取得

ここまでpaths/operationsを利用してAPIの実装をしてきました。
最後に作成したOpenAPI定義をSwagger UIで確認してみましょう。

ここまで作成したOpenAPI定義は http://localhost:3000/api-docs にアクセスすることで取得可能です。
関連するオプションについても簡単に記載します。

  • args.docsPath
    • default: /api-docs
    • API定義を取得するためにアクセスするエンドポイント
  • args.exposeApiDocs
    • default: true
    • APIドキュメントにアクセス可能とするかどうかのフラグ
    • 何かしらの理由でAPI定義を外部公開したくない場合にはfalseにする

SwaggerUIについてはDockerで簡単に立ち上げられます。
APIを試しに実行してみるというのがかなり楽にできます。

最後に

express-openapiを利用することで、expressとOpenAPI定義がより密接につながることが確認できました。
OpenAPI定義は単なる定義書には収まらないことが今回の記事からもわかると思います。

フォルシアではほかにもAPIの型定義をOpenAPI定義から生成したり、スクリプトの一部を自動作成するようなスクリプトを内製しています。
エンジニアがよりコアなロジック部分の実装に注力できるような環境を今後も整えていきたいと思います。

FORCIA Tech Blog

Discussion