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?

#194 ast-grep × ts-morphでAPIバリデーションを可視化する 〜 全体構成とscan層

0
Posted at

はじめに

前回記事では、express-validator を利用したバリデーション定義から、
API ドキュメントを自動生成するプロトタイプを題材に、

  • 単純な文字列解析では実務コードを扱いきれないこと
  • 「構文」と「意味」を分けて扱う必要があること
  • ast-grepts-morph を役割分担させる構成

について整理しました。


今回はその続きとして、
実際にどのような構成でツールを実装したのかを、コードベースで掘り下げていきます。


シリーズ記事

  1. ast-grep × ts-morphでAPIバリデーションを可視化する 〜 設計編

ただし、本記事で扱いたいのは単なる AST 操作のテクニック集ではありません。

  • なぜこの責務分離にしたのか
  • どの問題をどのレイヤで解決しているのか
  • なぜ一度 IR(中間表現)へ正規化しているのか

といった、「設計上の意図」を中心に見ていきます。

今回のディレクトリ構成

今回のプロトタイプでは、ツール全体を以下のような構成に整理しました。

tools/
  entry/
    index.ts               # オーケストレーションのみ

  scan/                    # Step 1: 静的スキャン(sg + regex)
    routeScanner.ts        # sg でルート・エンドポイントを収集
    validatorExtractor.ts  # バリデータ名・importパスを抽出

  analyze/                 # Step 2: 意味解釈(ts-morph)
    astWalker.ts           # AST走査(walk / resolveArgText)
    ruleInterpreter.ts     # calls → rules(interpretValidator)
    validatorResolver.ts   # resolveValidator / resolveCall / mergeValidators

  build/                   # Step 3: IR構築
    endpointBuilder.ts     # エンドポイントオブジェクトの組み立て

  output/                  # Step 4: 出力
    htmlGenerator.ts       # HTML生成

  utils/
    text.ts                # stripQuotes

  shared/
    types.ts               # 型定義

一見すると単純なレイヤ分割ですが、
ポイントとなるのが、「どの責務をどこに閉じ込めるか」をかなり強く意識している点です。


例えば、

  • scan は「どこに対象コードが存在するか」を見つける
  • analyze は AST を辿って「何を意味しているか」を解釈する
  • build は解釈結果を中間表現(IR)へ整理する
  • output は IR を表示へ変換する

というように、各層が扱う関心を意図的に分離しています。


このうち、本記事では主に以下を扱います。

  • ツール全体のパイプライン構成
  • scan 層の責務分離
  • ast-grep を利用した構造検索
  • 型を境界にしたレイヤ設計

一方で、

  • AST walk
  • 条件分岐の静的解決
  • validator の意味解釈
  • 関数 validator の展開

といった analyze 層の中核部分については、次回詳しく扱います。


今回の記事ではまず、

  • 「どこに解析対象が存在するか」
  • 「どこまでを scan 層で扱うのか」
  • 「なぜ analyze 層を分離しているのか」

という、ツール全体の土台となる設計を整理していきます。

1. 全体構成とパイプライン

1-1. パイプライン全体像

まずは、ツール全体の流れを整理します。


今回のツールでは、.routes.ts に定義された Express ルートを起点に、
段階的に情報を解釈しながら HTML へ変換しています。

.routes.ts

↓

scan
    - routeScanner

AstGrepMatch[]

↓

build
    - endpointBuilder
        ├── validatorExtractor(scan)
        └── validatorResolver(analyze)
            - astWalker
            - ruleInterpreter

Endpoint[]

↓

output
    - htmlGenerator

validation.html

重要なのは、この構造が「探索 → 解釈 → 正規化 → 表示」という段階に分かれている点です。


例えば scan 層では、

  • どのルートが存在するか
  • どの middleware が指定されているか

といった「構文上の情報」だけを扱います。


一方 analyze 層では、

  • body('email').isEmail()
  • required ? xxx : yyy
  • 関数化された validator

などを辿りながら、「この validator が実際に何を意味しているのか」を解釈していきます。


つまり、

  • scan は「対象の発見」
  • analyze は「意味の解釈」

という役割分担になっています。


さらに、その解釈結果を build 層で一度 IR(中間表現)へ整理し、最後に output 層で HTML へ変換しています。
この「一度 IR を挟む」という構造が、後述する疎結合化のポイントになります。

1-2. 型がパイプラインの境界になる

今回の構成では、各層を単にフォルダで分けているわけではありません。


shared/types.ts に定義した型を境界として、レイヤ間を接続しています。


例えば、analyze 層では以下のような型を扱っています。

export type CallInfo = {
  name: string;
  args: string[];
};

export type ValidatorField = {
  field: string | null;
  rules: Rule[];
};

export type Endpoint = {
  method: string;
  path: string;
  validators: ValidatorField[] | 'No validation rules';
};

データは内部で、概ね以下のように変換されていきます。

AstGrepMatch

(analyze)

ValidatorField

Endpoint

ここでポイントとなるのが、後段のレイヤが前段の内部実装を知らなくてよい、という点です。


例えば output 層は、

  • AST がどう走査されたか
  • import をどう解決したか
  • 条件分岐をどう評価したか

を一切知りません。


必要なのは、「最終的に整理済みの Endpoint[] が渡されること」だけです。
逆に analyze 層も、「最終的に HTML をどう描画するか」を知る必要はありません。


つまり、各層が「自分の責務だけ」を扱える構造になっています。


これは将来的に、

  • HTML ではなく Markdown 出力へ変更する
  • OpenAPI 形式へ変換する
  • Express 以外へ対応する

といった拡張を行う際にも有効です。


解析ロジックと表示ロジックが分離されているため、
出力形式を変更しても analyze 層にはほとんど影響がありません。


この「IR を境界に責務を切り分ける」という考え方は、今回のツール全体でかなり重要なポイントになっています。

2. Step1: scan

scan 層の責務は、「どこに解析対象が存在するか」を収集することです。


ここではまだ、

  • validator が何を意味するのか
  • 条件分岐がどう評価されるのか
  • メソッドチェーンがどう解釈されるのか

といった“意味”は扱いません。


あくまで、

  • どのルートが存在するか
  • どの middleware が指定されているか
  • validator がどのファイルから import されているか

という、「探索」に責務を限定しています。

2-1. routeScanner.ts

まずは、Express のルート定義そのものを収集する routeScanner.ts です。


このファイルの責務は主に以下の2つです。

  • ルート定義を収集する
  • router の basePath を解決する

■ routes ファイルの収集

まず、src/routes 配下から .routes.ts を列挙しています。

const routesFiles = fs
  .readdirSync(routesDir)
  .filter((fileName) => fileName.endsWith('.routes.ts'))
  .map((fileName) => path.join(routesDir, fileName));

ここでは AST を使わず、単純にファイルシステムから対象を列挙しています。
重要なのは、この段階ではまだ「中身を解釈しようとしていない」ことです。


scan 層はあくまで、「どこを解析対象にするか」を決定するフェーズとして割り切っています。

■ ast-grep による構造検索

ルート定義の抽出には ast-grep を利用しています。

const result = execSync(
  `sg run --pattern '$ROUTER.$METHOD($PATH, $$$AGRS)' ${routesFile} --json`,
  { encoding: 'utf-8' },
);

ここで重要なのは、これは単なる文字列検索ではなく、AST ベースの「構造検索」であるという点です。


例えば以下のようなコードに対して、

router.post('/users', createUserValidator, handler);

$ROUTER.$METHOD($PATH, $$$AGRS) というパターンを使うことで、

  • ROUTER → router
  • METHOD → post
  • PATH → '/users'
  • AGRS → middleware 群

を構文単位で取得できます。


ここでのポイントは、「完全な AST 解析」をしようとしていないことです。


scan 層では、

  • 「どこにルート定義があるか」
  • 「どんな形で書かれているか」

だけ取得できれば十分です。


そのため、型情報や関数展開が必要になる ts-morph はまだ使いません。

■ basePath の解決

次に、app.use('/api', router) のような mount 情報を解析し、router ごとの basePath を取得します。


ここでも同じく ast-grep を利用しています。

const mountResult = execSync(
  `sg run --pattern '$API.use($BASE_PATH, $ROUTER)' ${routesIndex} --json`,
  { encoding: 'utf-8' },
);

取得した結果を、最終的に以下のような map へ変換しています。

const routerBasePathMap = Object.fromEntries(
  mountMatches.map((match) => [
    match.metaVariables.single.ROUTER.text,
    match.metaVariables.single.BASE_PATH.text,
  ]),
);

これによって、

api.use('/users', usersRouter);

のような定義から、

{
  usersRouter: '/users'
}

という対応表を作成できます。


この情報は後続の build 層で、最終的な endpoint path を構築する際に利用されます。

■ routeScanner.ts のポイント

このファイルで重要なのは、「探索」と「意味解釈」を分離している点です。


ここでは、

  • ルート定義の場所
  • HTTP メソッド
  • middleware 群
  • basePath

は取得していますが、

  • validator が何を意味するか
  • middleware が何をしているか

は一切解釈していません。


つまり routeScanner.ts は「解析対象を見つけること」だけに責務を限定しています。

2-2. validatorExtractor.ts

次に、ルート定義から validator middleware を抽出する validatorExtractor.ts です。


このファイルの責務は以下です。

  • middleware 一覧の取得
  • import 元の特定
  • validator のみ抽出
export  function  extractValidators(args: AstGrepMetaVar[], filePath: string) {
  const  middlewares = parseMiddleware(args); // middleware 一覧の取得
  
  const  code = fs.readFileSync(filePath, 'utf-8');
  const  importMap = parseImports(code); // import 元の特定
  
  const  validatorMap = filterValidatorMap(middlewares, importMap); // validator のみ抽出

  return  validatorMap;
}

■ middleware の抽出

まず、ast-grep で取得した $$$AGRS から middleware 群を取り出します。

function parseMiddleware(args: AstGrepMetaVar[]) {
  const middleware = args.map((arg) => arg.text).filter((text) => text !== ',');

  return middleware;
}

ここで取得しているのは、あくまで middleware 名の文字列です。


例えば、

router.post('/users', auth, createUserValidator, handler);

なら、

['auth', 'createUserValidator', 'handler']

のような配列になります。

■ import 文の解析

次に、それらがどのファイルから import されているかを解析します。

const importRegex =
  /import\s+{([^}]+)}\s+from\s+['"]([^'"]+)['"]/g;

ここは AST ではなく、正規表現で実装しています。
理由は単純で、今回は import 形式をある程度限定したプロトタイプだからです。


例えば、

import { createUserValidator } from '../validators/user';

のような形だけ扱えれば十分だったため、 ここでは「完全な import 解決」は行っていません。
このあたりは、実用化を考えるなら改善余地がある部分です。

■ validator のみ抽出

最後に、middleware の中から validator のみを抽出します。

function  filterValidatorMap(
  middlewares: string[],
  importMap: Record<string, string>,
) {
  const  validatorMap: Record<string, string> = {};
  middlewares.forEach((name) => {
    const  importPath = importMap[name];
    if (!importPath) return;

    if (!importPath.includes('validators')) return;

    validatorMap[name] = importPath;
  });

  return  validatorMap;
}

ここでは、「validators ディレクトリ配下から import されているものだけを validator とみなす」というルールで、
かなり割り切った実装をしています。


今回はscan 層では、「候補を絞る」だけに留めています。
validator の中身を本当に解釈するのは、次の analyze 層です。

おわりに

今回は、API バリデーションドキュメント生成ツールの全体構成と、scan 層の実装について整理しました。


特に今回重要だったのは、

  • 「探索」と「意味解釈」を分離すること
  • scan 層では“候補を集める”ことに責務を限定すること
  • 型と IR を境界にして、各レイヤを疎結合に保つこと

の3点です。


scan 層では、AST 全体を重く解析するのではなく、

  • どこにルートがあるか
  • どの middleware が指定されているか
  • validator がどこから import されているか

だけを高速に収集しています。


一方で、実際の validator の意味解釈は、次の analyze 層へ委譲しています。


実務コードでは、

  • 関数化された validator
  • 条件分岐
  • 変数経由の message
  • 型情報からの値解決

などが登場するため、ここから先は単純な構文解析だけでは対応できません。


次回は、今回収集した validator を ts-morph でどのように解析し、

  • メソッドチェーンをどう walk するのか
  • 条件分岐をどう静的に解決するのか
  • validator の“意味”をどう Rule へ正規化するのか

といった、analyze 層の実装を掘り下げていく予定です。


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

参考

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?