Validate TypeScript による入力データ検証を試す

Web API で リクエスト パラメーターなりボディでクライアントからデータを受け取るケースがあります。
その際にリクエストを受け付けてよいか入力データの検証(入力チェックとか呼んだりも)しますが、最近 Validate TypeScript というライブラリを試してみたので ご紹介。

環境
本記事の開発環境は以下となります。

  • Windows 10 64bit + WSL Ubuntu 18.04.1 LTS
  • Visual Studio Code
  • Node.js 8.10.0
  • Yarn 1.13.0
  • TypeScript 3.3.4000
  • validate-typescript 4.0.1

Validate TypeScript 概要

Validate Typescript-https://github.com/Grant-Zietsman/validate-typescriptはスキーマベースのバリデーターで、JSON などのオブジェクトの値や型をチェックすることができる Node.js のライブラリです。MIT ライセンスの元 OSS で公開されています。
豊富な検証ルールをあらかじめ備えているほか、自分で拡張した検証ルールを使うこともできます。

Web API をはじめ、ウェブでリクエストを受け取る機能を作った場合に、クライアントからリクエストしてきた内容を受け入れてよいかチェックする必要があります。
たとえばメールのアドレスは半角の英数字から始まり@が間にあって、ドメイン名のルールが(実際にはもっと複雑)などと形式があっているかチェックする必要があります。他にもドロップダウンから選択したデータが選択できる範囲の値であるか(不正データを送ってきてないか)や、必須入力ではないけど送ってきたら数値である必要がある、などと単純な型チェックだけでもいろいろとやることがあります。

Validate Typescriptは、その「型チェック」を支援してくるライブラリになります。(逆に言うとメールアドレスが存在するかのチェックや、DB に存在する値との整合性、入力データ間の整合性などはしてくれません。こちらはこちらで必要なことは別途実装する必要があります。)

その「型チェック」ですが色々なやり方があります。各リクエストパラメーターごとにチェックの関数を呼び出したり、リクエストパラメーターに対応するクラスを定義してデコレーター(アノテーション)を付けたり。z
今回Validate Typescriptに着目したのは “スキーマベース” という点で、スキーマをコード内のオブジェクトで渡せるところが良いと思い使ってみることにしました。

こちらのライブラリを使おうとしたところ、インストールエラーが出てしまってプルリクを出して直してもらったことがありました。詳しくは「Validate Typescript に インストールエラーの修正についてのプルリクを送る」の記事になります。こうしてプルリクで改修や機能の提案できるのが OSS のよいところですね。

Validate TypeScript のインストールとサンプルコード

インストールは Node.js プロジェクトで NPM パッケージとして導入します。
yarn add validate-typescript
(npm の場合はnpm install validate-typescript)

公式のサンプルコード抜粋(一部修正)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import { Alias, Email, ID, Optional, Options, RegEx, Type, validate } from 'validate-typescript';
import { AssertionError, ValidatorError } from 'validate-typescript/lib/errors';

const zaPhoneNumber = (): string => Alias(RegEx(/^((\+27|0)\d{9})$/), zaPhoneNumber.name);

class CustomMessage {
public message!: string;
}

const schema = {
id: ID(),
children: [ID()],
username: Type(String),
email: Email(),
gmail: RegEx(/.+@gmail.com/),
phone: zaPhoneNumber(),
gender: Options(['m', 'f', 'o']),
married: Type(Boolean),
names: {
first: Type(String),
middle: Optional(Type(String)),
last: Type(String)
},
message: Type(CustomMessage)
}

const data = {
id: '17s',
children: [1, 2, '3s'],
username: 'solomon',
email: 'solomon@validate-typescript.com',
gmail: 'solomon@gmail.com',
phone: '+27824392186',
gender: 'm',
married: true,
names: {
first: 'Solomon',
last: 1
},
message: Object.assign(new CustomMessage(), { message: 'Sawubona Mhlaba' })
};

try {
const input = validate(schema, data);
console.debug(input); // no validation error
} catch (error) {
console.debug(JSON.stringify(error)); // validation error

const violations: ValidatorError[] = [];
JSON.parse(JSON.stringify(error), (key: string, values: ValidatorError[]) => {
if (key === 'child_errors') {
Array.prototype.push.apply(violations, values.filter((value: ValidatorError) => value.property ? value : undefined));
}
return values;
});

for (const violation of violations) {
console.debug('%s=%s %s', violation.property, violation.value, (violation.child_error as AssertionError).details);
}
}

前半のzaPhoneNumberはカスタムの入力検証で南アフリカ共和国の+27または0始まりの電話番号チェックです。
CustomMessageはデータ構造の一部をクラスとして表現し、オブジェクトリテラルとの複合を試している感じでしょうか。

const schemaが本題の入力検証でチェックするデータスキーマです。
データとして受けとるキー: 入力検証のルールという形式で記述します。
たとえばid: ID()は入力データidというキーにID()の形式(実態は数値)になっているかをチェックするといった具合です。
Type(String)のような単純な型チェックや、Email()などの組み込み型、RegEx()による正規表現のチェックなどがあります。
また必須ではないが受け取った場合はチェックするのOptional()に、来た場合のチェックを引数にルールを入れる。
具体的な値の選択肢であるOptions()などもあります。
ひととおりのチェックルールはまかなえています。

サンプルではconst dataで入力データを記述していますが、実際には Web API などを作っているフレームワークなりの入力を受けて、validate(schema, data);を実行します。
入力検証が通るとvalueが戻り値で返ってきますが、問題がある場合はエラーがスローされてくるので呼び出すだけでもよいかもしれません。

問題があった場合のエラーの受け取りですが、ValidatorError と AssertionError の再帰構造となっているようです。
入力データの構造が深くなると、それに合わせてchild_errorsという形でエラーも深くなるようになっています。
必要な部分だけを抜粋すると以下のような構造になります。下記はidの値が数値でなかった場合のエラーです。

1
2
3
4
5
6
7
8
9
10
11
12
{
"message": "Validate TypeScript",
"validator": "ID",
"property": ".id",
"value": "17-error",
"child_error": {
"message": "Validate TypeScript",
"value": "17-error",
"converter": "toInt",
"details": "could not be converted to an integer"
}
}

propertyvalueで、キーと受け取った値がわかります。(キーの先頭にドットがついてますが)
child_errordetailsにエラーメッセージが入っています。英語ですが、そのまま使えそうな感じです。

ただしデータの階層が深い場合は、このデータ構造がchild_errorsという形で深く連なっていくので、そのままでは扱いにくいです。
そのためサンプルに加えて以下のようなコードを追加してフラットに変換しています。

1
2
3
4
5
6
7
const violations: ValidatorError[] = [];
JSON.parse(JSON.stringify(error), (key: string, values: ValidatorError[]) => {
if (key === 'child_errors') {
Array.prototype.push.apply(violations, values.filter((value: ValidatorError) => value.property ? value : undefined));
}
return values;
});

JSON をパースする際にreviver 関数を使ってchild_errorsからpropertyが存在する場合に、その値を取得して配列で保持するようにしています。
reviver 関数は JSON をパースする際に値を変換することができるのですが、再帰構造から必要なものだけをうまく引き出せなかったので、横から取り出すようにしました。
これで具体的な入力検証エラーの部分だけを取り出せるたので、必要に応じてレスポンスするためのオブジェクトに詰めなおすことができます。


シンプルなスキーマで、その場に書けるのでコーディングがしやすくきになるらいぶらりなのですが、入力検証エラー時の構造体がちょっと扱いにくいのと、エラーの型定義がサブモジュールのインポートになってしまうため、TSLintを厳しくしていると怒られるという点があり気になりました。

入力検証は似て非なるデータ構造のチェックになり、クラスのデコレータベースは使いにくいと思っているので、スキーマベースという点が気に入っているのですが、エラーのデータ構造がちょっと気になります。
とくに同じキーchild_errorsに入っていてpropertyがあるかないかで、末端のエラーが情報か、中間のコンテナーかを判別しないといけないのが、あとからコードを見た時に何をしたいのか分かりにくいだろうなぁと。

もう少し使いやすいものを探してみつつ、時間切れとなったらValidate Typescriptを使わせてもらおうかな。