0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

#196 ast-grep × ts-morphでAPIバリデーションを可視化する 〜 「構文」から「意味」へ変換するanalyze層

0
Posted at

はじめに

前回記事では、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バリデーションを可視化する

  1. 設計編
  2. 全体構成とscan層

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,
};

ここでは、

  • isEmail
  • withMessage
  • optional

などを「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'] }
]

ただし、この時点ではまだ、

  • isEmail
  • optional
  • isLength

などが「何を意味するか」は解釈されていません。

ここで登場するのが 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;
};

これによって後続のレイヤは、

  • isEmail
  • isUUID
  • notEmpty

といった 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.tsruleInterpreter.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 を知らずに済む理由

以上です。最後まで閲覧いただきありがとうございます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?