はじめに
前回記事では、API バリデーションドキュメント生成ツールの実装を題材に、
-
ast-grepを利用したルート探索 - validator の収集
- scan 層の責務分離
について整理しました。
Step1 の scan 層では、
- どこにルートが存在するか
- どの validator が利用されているか
までを取得します。
しかし、実際にドキュメントとして出力することを前提とすると、
scan 層だけではまだまだ必要な情報が不足しています。
例えば、
body('email')
.isEmail()
.withMessage(msg)
という validator で考えると、
-
msgの実際の値 -
isEmail()が表す意味 - 条件分岐の結果どちらの validator が採用されるのか
など、「この validator が最終的に何を意味するのか」というような情報は、
この時点ではまだ取得できていません。
この「構文情報を意味のあるバリデーションルールへ変換する」という役割を担うのが、
今回取り上げる Step2 の analyze 層です。
Step1: scan // シリーズ第2回記事
↓
Step2: analyze ← 今回
↓
Step3: build
↓
Step4: output
本記事では、
- AST をどのように辿るのか
- 条件分岐をどう静的に解決するのか
- 関数 validator をどう展開するのか
- バリデーションルール をどう正規化するのか
といった、ツールの中核となる実装について見ていきます。
シリーズ記事:ast-grep × ts-morphでAPIバリデーションを可視化する
Step2: analyze
1. analyze 層の全体像
まずは analyze 層のデータフローを整理していきましょう。
以下の validator を例としてざっくり見ていくと、
内部では以下のように変換されていきます。
body('email')
.isEmail()
.withMessage(msg)
AST
↓
astWalker
↓
CallInfo[]
*******************************************
[
{ name: 'isEmail', args: [] },
{ name: 'withMessage', args: ['msg'] }
]
*******************************************
↓
ruleInterpreter
↓
Rule[]
*******************************************
[
{
type: 'email',
message: 'メール形式で入力してください'
}
]
*******************************************
なお、実際のツールでは、以下のような構成をとっています。
// フォルダ構成
analyze/
astWalker.ts
ruleInterpreter.ts
validatorResolver.ts
// 依存関係と役割
validatorResolver // 関数展開や validator 解決を管理する
├── astWalker // AST を辿ってメソッドチェーンを抽出する
└── ruleInterpreter // 抽出した call 情報をバリデーションルールへ変換する
validatorResolver.ts が
analyze 層全体のオーケスとレーターとして解析全体を制御し、
- AST の走査
- 条件分岐の解決
- 関数 validator の展開
- ルールへの変換
を組み合わせながら、最終的なバリデーション情報を構築しています。
重要なのは、この段階で
- AST の構造
- ts-morph の Node
- PropertyAccessExpression
といった低レベル情報を、後続へ渡さないことです。
astWalker.ts は AST を「辿る」責務に限定し、
意味解釈そのものは ruleInterpreter.ts に委譲しています。
この分離によって、
- AST の探索ロジック
- バリデーションルールの意味解釈
を独立して変更できる構造としています。
では実際に、astWalker.ts の中身から見ていきましょう。
2. walk() によるチェーン走査
astWalker.ts は analyze 層の中でも、最も「AST に近い」レイヤです。
責務はシンプルで、
「validator のメソッドチェーンを辿り、CallInfo の列へ変換すること」です。
中心になるのは walk() 関数です。
function walk(node: Node, paramValues: Record<string, string> = {}) {
この関数は AST を再帰的に辿りながら、
body('email').isEmail().withMessage(msg)
のようなチェーンを、
[
{ name: 'isEmail', args: [] },
{ name: 'withMessage', args: ['msg'] }
]
へ変換します。
実際にチェーンを積み上げているのは以下の部分です。
return {
calls: [...inner.calls, { name: callName, args }],
fieldArg: inner.fieldArg,
};
ここでは、
isEmailwithMessageoptional
などを「CallInfo の配列」として収集しています。
この時点ではまだ、
-
isEmailがどういう意味か -
optionalをどう扱うか
は判断していません。
つまりここでは、
「AST 構造を線形な call 情報へ変換する」ことだけに責務を限定しています。
2-1. walk() が解決している3つの問題
この walk() は、単にチェーンを辿るだけではありません。
実際には、以下のような「静的解析時の問題」も同時に扱っています。
■ メソッドチェーンの抽出
まず基本となるのが、
body('email').isEmail().withMessage(msg)
のような call chain の分解です。
AST 上では deeply nested な構造になっていますが、
walk() ではそれを順番付きの call 配列へ変換しています。
■ 条件分岐の静的解決
実務コードでは、validator が条件によって分岐することがあります。
例えば、
required
? body('title').notEmpty()
: body('title').optional()
のようなケースです。
これに対応するため、walk() では ConditionalExpression を扱っています。
if (node.isKind(ts.SyntaxKind.ConditionalExpression)) {
さらに、
paramValues[condName] === 'true'
のように、関数引数から条件を静的に解決しています。
つまり analyze 層では、
「コードを実行せずに、どちらの branch が使われるか」を推論しています。
なお、上記以外の表現も実際のプロジェクトでは出てくるかと思います。
実態に合わせた解決が必要となるので、考え方の参考にしてみてください。
■ 型・変数からの値解決
もう1つ重要なのが、引数の値解決です。
例えば、
const roles = ['admin', 'staff'] as const;
body('role').isIn(roles)
のようなコードでは、roles をそのまま文字列として扱うだけでは不十分です。
そのため resolveLiteralValuesFromType() では、
if (type.isTuple()) {
のように型情報から literal value を取得しています。
結果として、
["admin", "staff"]
という値へ解決できます。
また、withMessage(msg) のようなケースでは、
変数 initializer を辿って値を取得しています。
const scopedInitializer = fn
?.getDescendantsOfKind(ts.SyntaxKind.VariableDeclaration)
ここで重要なのは、
analyze 層では「コードの意味」を補完するために型情報を利用している、という点です。
単に AST を辿るだけではなく、
- 型
- 変数
- 条件分岐
まで含めて静的に解釈しています。
2-2. astWalker.ts の責務
ただし、それでも astWalker.ts は「意味解釈そのもの」はしていません。
最終的に返しているのは、あくまで以下です。
{
calls,
fieldArg
}
つまり、
- どんな call が存在したか
- field が何だったか
だけです。
例えば、
isEmail
が「メールアドレス形式を意味する」という知識は、まだ持っていません。
その責務を担当するのが、次の ruleInterpreter.ts です。
3. interpretValidator() によるバリデーションルールへの変換
astWalker.ts によって、validator のチェーンは以下のような CallInfo[] へ変換されました。
[
{ name: 'isEmail', args: [] },
{ name: 'withMessage', args: ['msg'] }
]
ただし、この時点ではまだ、
isEmailoptionalisLength
などが「何を意味するか」は解釈されていません。
ここで登場するのが ruleInterpreter.ts です。
このファイルの責務は、
「CallInfo の列を、ツール内部のバリデーションルールへ変換すること」です。
3-1. 「構文」から「意味」への変換
中心になるのは interpretValidator() です。
export function interpretValidator({
calls,
field,
}: {
calls: CallInfo[];
field: string | null;
})
ここでは calls を順番に走査しながら、
if (name === 'isEmail') {
rules.push({ type: 'email' });
}
のように、「validator の意味」を Rule 型へ変換しています。
例えば、
body('email').isEmail()
は最終的に、
[
{
type: 'email'
}
]
という内部表現になります。
重要なのは、ここで初めて「validator の意味知識」が登場していることです。
つまり、
-
astWalker.ts
→ 「何が呼ばれたか」 -
ruleInterpreter.ts
→ 「それが何を意味するか」
という責務分離になっています。
3-2. Rule 型へ正規化する
このレイヤで重要なのは、express-validator の API をそのまま扱わないことです。
例えば isUUID() や isEmail() を、直接 HTML 出力へ流してしまうと、
- 出力形式
- validator ライブラリ
- 内部表現
が密結合になります。
そのため、一度 Rule 型へ正規化しています。
export type Rule = {
type:
| 'uuid'
| 'required'
| 'optional'
| 'length'
| 'email'
| 'in'
| 'custom';
min?: number;
max?: number;
values?: string[] | string;
message?: string;
};
これによって後続のレイヤは、
isEmailisUUIDnotEmpty
といった express-validator 固有の API を知らなくて済みます。
例えば output 層は、
if (r.type === 'email') {
return 'メールアドレス形式';
}
のように、単純な IR として扱えます。
3-3. withMessage の扱い
少し面白いのが withMessage() の扱いです。
if (name === 'withMessage') {
if (rules.length > 0) {
rules[rules.length - 1].message = msg ?? undefined;
}
}
withMessage() は単独で意味を持つ validator ではなく、
「直前のバリデーションルールを修飾する API」です。
そのため、この実装では、
- 最後に追加されたルール
- つまり「直前の validator」
へ message を付与しています。
例えば、
body('email')
.isEmail()
.withMessage('invalid')
は、
[
{
type: 'email',
message: 'invalid'
}
]
へ変換されます。
ここは AST 構造そのものではなく、
「express-validator のメソッドチェーンの意味」を解釈している箇所です。
つまり ruleInterpreter.ts は単なる call mapping ではなく、
「validator API のドメイン知識」を持っているレイヤと言えます。
3-4. isLength の解析
isLength() は少し特殊です。
body('title').isLength({ min: 1, max: 20 })
のように、object literal を引数に取るためです。
そのため、このコードでは一度 object を parse しています。
const parsed: unknown = eval(`(${str})`);
さらに、
function isLengthOption(
value: unknown,
): value is { min?: number; max?: number }
という型ガードを用意し、
- min
- max
を持つ object だけを許可しています。
ここはかなりプロトタイプ寄りの実装です。
実運用を考えるなら、
- AST から object literal を読む
- eval を避ける
- 型安全に parse する
など改善余地があります。
ただし今回重要なのは、
analyze 層で「意味解釈」を閉じ込めていることです。
多少実装を差し替えても、後続の Rule 型は維持できます。
3-5. custom() をどう扱うか
もう1つ重要なのが custom() の扱いです。
if (name === 'custom') {
rules.push({ type: 'custom' });
}
これは意図的に、
「custom validator が存在する」ところまでしか解析していません。
理由は単純で、custom() は任意関数だからです。
例えば、
custom(async (value) => {
await checkUser(value);
})
のようなコードを、静的解析だけで正確に理解するのは非常に難しくなります。
つまりここには、
「AST ベース解析の限界」があります。
今回のツールでは、この問題を無理に解決しようとはせず、
{ type: 'custom' }
として「未知のバリデーションが存在する」ことだけを保持しています。
これは後続き時で触れる予定の、
- 実現できたこと
- 難しかったこと
- AST 静的解析の限界
にも繋がるポイントです。
4. analyze 層のオーケストレーター
astWalker.ts と ruleInterpreter.ts によって、
- validator chain の抽出
- ルールへの意味変換
はできるようになりました。
ただし、実務コードでは validator は単純な1行とは限りません。
例えば、
const titleValidator = (required: boolean) =>
required
? body('title').notEmpty()
: body('title').optional();
のように、
- 関数化
- 引数化
- 条件分岐化
されているケースがあります。
そのため analyze 層には、
「validator を“展開”して解決する役割」が必要になります。
それを担当するのが validatorResolver.ts です。
このファイルは analyze 層の中でも、比較的「上位レイヤ」に位置しており、
内部では、上記で説明した
- astWalker.ts の
walk() - ruleInterpreter.ts の
interpretValidator()
をそれぞれ呼び出しています。
const { calls, fieldArg } =
unwrapExpression(expression, paramValues); // 内部で `walk()` を呼び出す
return resolveCall( // 内部で `interpretValidator()` を呼び出す
{ calls, field: fieldArg },
sourceFile,
paramValues,
);
4-1. validator 配列の展開
まず入口になるのが resolveValidator() です。
export function resolveValidator(
names: string[],
sourceFile: SourceFile,
)
ここでは validator 名から variable declaration を取得し、
const variable =
sourceFile.getVariableDeclaration(name);
さらに initializer を辿っています。
const initializer = variable.getInitializer();
validator が array literal の場合、その中の各 expression を解析対象として展開します。
つまり、
[
body('email').isEmail(),
passwordValidator(true),
]
のような構造を、再帰的に解釈していきます。
4-2. 関数 validator の展開
特に重要なのが関数 validator の展開です。
if (calls.length === 1) {
ここでは、
titleValidator(true)
のような call を検出した場合、
- 関数定義を探す
- 引数を paramValues へマッピング
- return expression を取得
- 再度 resolveExpression()
という流れで解析しています。
関数探索は findFunction() が担当しています。
const variable =
sourceFile.getVariableDeclaration(name);
さらに、return expression を取り出して、
return returnStatement?.getExpression();
再び analyze 処理へ戻します。
ここで重要なのは、
「関数を実行する」のではなく、「関数の戻り値 AST を静的に辿っている」という点です。
つまり analyze 層全体で一貫して、「実行ではなく静的解釈」を行っています。
4-3. mergeValidators()
最後に mergeValidators() で field 単位にルールを統合しています。
const existing = map.get(validator.field) ?? [];
map.set(
validator.field,
[...existing, ...validator.rules],
);
これによって、
body('email').isEmail()
body('email').notEmpty()
のような validator を、
{
field: 'email',
rules: [
{ type: 'email' },
{ type: 'required' }
]
}
へまとめています。
ここで初めて、「field ごとのバリデーション情報」という、かなり完成形に近い IR になります。
つまり validatorResolver.ts は、
- AST walk
- 関数展開
- 条件解決
- ルール解釈
を統合しながら、
最終的に ValidatorField[] を構築するレイヤになっています。
おわりに
今回は、API バリデーションドキュメント生成ツールの中核となる analyze 層について整理しました。
scan 層では「どこに validator が存在するか」までしか分かりませんでしたが、
analyze 層で
- 関数展開
- 条件分岐の解決
- 型情報からの値取得
- バリデーションルールへの変換
を行うことで、
body('email')
.isEmail()
.withMessage(msg)
のようなコードを
{
"type": "email",
"message": "メール形式で入力してください"
}
という意味を持ったデータへ変換できるようになりました。
特に今回の実装では、以下をそれぞれ別のレイヤに分離しています。
- AST を辿る責務
- validator の意味を解釈する責務
- validator を展開・統合する責務
これは単に実装を分割するためだけではなく、
- AST の探索方法を変更する
- 新しい validator を追加する
- 解釈ロジックを改善する
といった変更を局所化するためでもあります。
また、今回扱った内容は「コードを読む」ための仕組みであり、
まだ最終的なドキュメント生成までは行っていません。
次回は、Step の後半部分として以下の内容を取り上げながら、
このツール全体の設計を振り返っていきたいと思います。
- analyze 層で得られた情報をどのように IR へ整理するのか
- なぜ build 層を独立させているのか
- HTML 出力層が AST を知らずに済む理由
以上です。最後まで閲覧いただきありがとうございます。