見出し画像

ExpressでREST APIを実装する時にreq/resのvalidationを楽に実装できるライブラリを使ってみた

はじめに

こんにちは、SHIFT の開発部門に所属している Katayama です。

REST API を Express で実装する際に request のスキーマ validation・sanitization をするには?の記事に書いたように、REST API のリクエストのバリデーション実装は、express-validatorといったライブラリを使ってできます。

ただ、このライブラリの場合、

router.get(
	'/todo/:id',
	checkSchema({
		id: {
			in: ['params'],
			isInt: {
				options: { min: 1 }
			},
			toInt: true,
			exists: true
		}
	}),
	(req, res) => {...}
);

のように、

・同じような validation をしたい場合に、DRY ではなくなり、何度も同じような実装をしてしまう
・API の数が増えるとその分 1 つ 1 つ上記のような実装が必要で手間
・実装漏れなどで API の設計書との乖離が生じても気づきにくい(テストするには全てのパターン網羅なテストを書く必要がある)

などデメリットもあるかと思います。そこで今回はせっかく OpenAPI という標準化された仕様があるので、その OpenAPI で API を設計すればバリデーション(リクエスト・レスポンスの両方)を簡単に実装できるライブラリを使った実装をやってみたいと思います。また、OpenAPI のプロパティを snake_case で書いている時に、JavaScript の中は cameCase にしたい・・・と言った場合でも毎回 case の変換処理を実行しないでよくなる方法についても見ていきたいと思います。

使うライブラリについて

使うのはexpress-openapi-validatorというライブラリで、

An OpenApi validator for ExpressJS that automatically validates API requests and responses using an OpenAPI 3 specification.

と書かれているように、OpenAPI の定義に基づいて API のリクエスト・レスポンスの各スキーマのバリデーションをしてくれる便利なライブラリ。ただし、OpenAPI の version は 3 なので注意。

※OpenAPI についてはSwagger OpenAPI Guide等の説明を参照。また、OpenAPI で REST API を設計してみたも見て頂ければ幸い。

実際に実装してみる

結論:どうすればいいか?

以下の"reqCaseConverter"と"resCaseConverter"は case 変換に関わる実装で、単に OpenAPI の定義に基づいて validation を実行するだけであれば"OpenApiValidator.middleware(...)"だけ実装すればいい。

//
import * as OpenApiValidator from "express-openapi-validator";
import appRoot from "app-root-path";
import camelcaseKeys from "camelcase-keys";
import snakecaseKeys from "snakecase-keys";

const reqCaseConverter = () => {
  return (req, res, next) => {
    if (req.originalUrl.startsWith("/api/v1/user")) {
      if (req.body) req.body = camelcaseKeys(req.body, { deep: true });
      if (req.query) req.query = camelcaseKeys(req.query);
    }
    next();
  };
};
const resCaseConverter = () => {
  return (req, res, next) => {
    if (req.originalUrl.startsWith("/api/v1/user")) {
      const originFunc = res.json;
      res.json = (data) => {
        return originFunc.call(res, snakecaseKeys(data));
      };
    }
    next();
  };
};

export default () => {
  const middleware = OpenApiValidator.middleware({
    apiSpec: appRoot.resolve("src/openapi/user.yaml"),
    validateRequests: true,
    validateResponses: true,
    ignorePaths: (p) => !p.startsWith("/api/v1/user"),
  });

  middleware.push(reqCaseConverter());
  middleware.push(resCaseConverter());

  return middleware;
};

以下の項で上記の詳細を見ていく。

OpenAPI の定義で validation を実行させる

一番最初の導入するために必要な事はUsageに書かれているのでそちらを参照。

※1 点注意として、

3.Register an error handler

の項のように実装する事で以下のように Express のデフォルトのエラーハンドラーを上書きしているが、これは express-openapi-validator の方でリクエスト・レスポンスのスキーマチェックをした結果、OpenAPI の定義と不一致な場合にエラーが発生するが、それをキャッチして JSON で返すための実装。もしこの実装をしなければデフォルトのエラーハンドラーの処理で HTML が返ってしまう。

app.use((err, req, res, next) => {
  res.status(err.status || 500).json({
    message: err.message,
    errors: err.errors,
  });
});

※Express のデフォルトのエラーハンドラーについては、Express の error handling を理解し、middleware で実装してみたを参照頂ければ幸い。

続いて、OpenApiValidator Middleware Optionsの options の中で今回設定してる部分について、以下で 1 つ 1 つ見ていく。

apiSpec

参照する OpenAPI の JSON・yaml の場所の設定ができるキー。今回はapp-root-pathも組み合わせて相対パスではなく、ルートパスからのパスで指定している。

ignorePaths

指定したパスの API に対するバリデーションを off にする設定ができるキー。正規表現で書ける。公式にある通り、関数を書いた場合にはそれが true になるものをバリデーションの対象から除外する。

今回は "/api/v1/user" で始まらないパスの API に対するバリデーションは off にするようにしている(OpenAPI の定義にないパスのバリデーションをしないように明示的に設定している)。

※"ignorePaths: (p) => p.startsWith('/api/v1/user')" のようにすると、今回の OpenAPI に基づいて実装した全ての API に対するバリデーションが off になるので、真偽値の返り値には注意。

case 変換の middleware ロジックについて

今回の OpenAPI では、スキーマのプロパティを snake_case にしているが、JavaScript の中では camelCase でプロパティにアクセスしたり、res.json()を書いたりしたい。そのため、middleware でその変換をかませるようにした。その変換の middleware は "openapi-validator.js" とは別に "index.js" 等で定義してもよかったが、case 変換する対象を OpenAPI のパスに合わせたかったので "openapi-validator.js" 内で middleware を定義してる。

※この辺りはそもそも camelCase にすべきという話(API 設計スキルを次のレベルに引き上げるベストプラクティス 22 選)もあるようだが、例えばOpenID Connectではそのプロパティは snake_case になっていたり、色々あるようなので今回は snake_case で OpenAPI の設計をした。

request の case 変換(snake_case → camelCase)

request は query パラメータと Body 部の 2 つ(path パラメータにはキーがないので case は関係ない)で case 変換が必要になるので、その 2 つで case 変換を行うようにしている。

middleware.push((req, res, next) => {
	...
		if (req.body) req.body = camelcaseKeys(req.body, { deep: true });
		if (req.query) req.query = camelcaseKeys(req.query);
	...
	next();
});

特に難しい事はなく、元々の値をcamelcase-keysで変換し、変換後の Object で req.body や req.query を上書きしている。1 点、"req.body" の方はdeepオプションを true にしているが、これはリクエスト Body がネストした Object である場合も想定しているため。

これで router.post()などの handle 関数内で実行する時に、特にリクエストの query パラメータや Body 部の case を意識することなく実装する事ができるようになる。実際に router の方で呼び出す時の例としては以下。

app.use("/api/v1", router);
router.post("/user", (req, res) => {
  console.log(req.body);
  res.status(200).json({ id: 1, ...req.body, createdAt: 111111 });
});

curl でこのエンドポイントにリクエストを送ると、ちゃんとsnake_case→camelCase の変換がされている事が確認できる。

[root@localhost node-express]# curl http://localhost:3000/api/v1/user -X POST -H "Content-Type: application/json" -d '{"email":"sample@example.com", "first_name":"田中"}'
{"id":1,"email":"sample@example.com","first_name":"田中","created_at":111111}
...
listening on port 3000!
{ email: 'sample@example.com', firstName: '田中' }

response の case 変換(camelCase → snake_case)

Override Node.js Express response methodNode.js / Express.js - How to override/intercept res.render function?の内容を参考に、res.json のメソッドを上書きしてsnakecase-keysでレスポンス Body を変換するようにしている。

middleware.push((req, res, next) => {
	...
		const oldResJson = res.json;
		res.json = (data) => {
			return oldResJson.call(res, snakecaseKeys(data));
		};
	...
	next();
});

上記の実装は、express のWriting middleware for use in Express appsという仕組みと、JavaScript 特有のprototype(Function.prototype.call)を使い、response の Body 部の JSON を cameCase から snake_case に変換している。

具体的に見ていく。 まず、express が持つres.json()メソッド(関数)をそのまま実行させないように、middleware を定義する(既にある res.json のプロパティの中身を上書きする)。これが "res.json = (data) => {...}" の部分。

ただ、express に元々実装されているres.json()メソッドは、その実装を見ると分かるが、それになりにうまい事やってくれているので、できれば自分で新しく関数を実装するのではなく、この関数の実装に乗っかりたい。

そこで、express に実装されている res.json の関数を、一旦別の変数(今回だと "originFunc" )に格納し、JavaScript の Function なら必ず持つ prototype(Function というオブジェクトのインスタンスが必ず持つメソッド)であるFunction.prototype.call()を使い、変数("originFunc")に格納した関数を呼び出すようにする事で、res.json()関数の実装をそのまま使えるようにする。"call()" で関数を呼ぶ時には、その関数の引数に渡す値を call()の第 2 引数以降に定義できるので、そこでsnakecase-keysを使って case 変換(cameCase → snake_case)を実行した後の JSON を渡すようにしている(下図を参照)。

これで res.json()を router.get()などの handle 関数内で実行する時に、特に cameCase 等の case を意識することなく実装する事ができるようになる。実際に router の方で呼び出す時の例としては以下。

app.use("/api/v1", router);
router.get("/user/:userid", (req, res) => {
  res
    .status(200)
    .json({ id: 1, email: "sample@example.com", createdAt: 111111 });
});

curl でこのエンドポイントにリクエストを送ると、ちゃんと camelCase→snake_case の変換がされている事が確認できる。

[root@localhost node-express]# curl http://localhost:3000/api/v1/user/1
{"id":1,"email":"sample@example.com","created_at":111111}

※ちなみに、response.jsで "var app = this.app;" となっている部分があるのに、 "originFunc.call(res, ...)" で "res" を this として渡していいのか?という疑問があるが、これはinit.jsの部分で res オブジェクトに "app.response" prototype プロパティが追加されており、 "res.app" が存在するので問題ない。それは実際に console に出力させて確かめる事ができる。

router.get("/", (req, res) => {
  const prototype = Object.getPrototypeOf(res);
  console.log(Object.getOwnPropertyNames(prototype));
  res.status(200).end();
});
[ 'app' ]

・参考:Object のプロトタイプ
・参考:Express の req Properties

補足:case 変換の middleware の実装について

今回は case 変換の middleware を別のモジュールに分割しないで実装したが、以下のように分割してそれを呼び込む形でもいいかもしれない。

// request-case-convert.js
export default () => {
	return (res, req, next) => {
		if (req.originalUrl.startsWith('/api/v1/user')) {
			...
		}
		next();
	};
};
// openapi-validator.js
import requestCaseConvert from './request-case-convert'
export default () => {
	const middleware = OpenApiValidator.middleware({
		...
	});

	middleware.push(requestCaseConvert());

	return middleware;

まとめとして

今回はexpress-openapi-validatorを使った REST API のバリデーションの実装についてみてきたが、これだと OpenAPI をそのまま使うので、設計書から実装が漏れる心配や設計変更ごとに都度実装を変えるなどの手間が削減でき、非常に便利だと思われる。今後 API を Node.js で実装する事があればこれを使っていきたいと思った。

おまけ

Express の middleware は array(配列)で設定できる

今まで Express の middleware の設定は array でできる事を知らなかったが、どうやらできるらしい。実際に以下のようなコードを書く事ができる。

const requestTime = (req, res, next) => {
  req.time = Date.now();
  next();
};
app.use([
  (req, res, next) => {
    req.time = Date.now();
    next();
  },
  (req, res, next) => {
    req.timeFunc = () => {
      return Date.now();
    };
    next();
  },
]);

これについてはUsing middleware

Middleware can also be declared in an array for reusability. This example shows an array with a middleware sub-stack that handles GET requests to the /user/:id path

のように書かれている。

"middlewares.push(...)"とできるのはなぜ?

"OpenApiValidator.middleware(...)"で作られた返り値に対して "middlewares.push(...)" のように push でできているのか?についてみていく。

"OpenApiValidator.middleware(...)" のように関数を呼び出しているがその中身は、 index.ts の "export const middleware = openapiValidator;" で、これは "openapiValidator" で実装されている。そして"return oav.installMiddleware(...)" の "installMiddleware" は このように実装されており、これを見ると、 "const middlewares: OpenApiRequestHandler[] = [];" で定義されている middlewares という配列に "pathParamsMiddleware" や "requestMiddleware" といった関数(express の middleware 関数)を push した後、その "middlewares" を return している事が分かる。

つまり、"OpenApiValidator.middleware(...)" で呼び出した関数の返り値は express の middleware 関数の配列であり、それを express の middleware にまとめて全部登録しているのである。

という事で、"OpenApiValidator.middleware(...)" の返り値に対して、追加の middleware(ケース変換をする middleware)を追加できる。

※今回この記事で見ているソースコードの version は、2021 年 12 月 3 日時点の mater ブランチの状態のものなのでそこは注意。


《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 昨年に開発部門へ異動し、再び開発エンジニアに。フロントエンド・バックエンドの両方を担当。座学で読み物を読むより、色々手を動かして試したり学んだりするのが好きなタイプ。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/