はじめに
前回記事では、API バリデーションドキュメント生成ツールの中核となる analyze 層について紹介しました。
scan 層で収集した validator を ts-morph を用いて解析し、
- AST の走査
- 条件分岐の静的解決
- 関数 validator の展開
- バリデーションルールへの変換
を行うことで、「コードが何を意味するのか」を解釈するところまで実装しました。
しかし、この時点で得られているのは、あくまで解析結果です。
そのままでは API ドキュメントとして利用することはできません。
そこで今回は、analyze 層で得られた情報をエンドポイント単位のデータへ組み立て、最終的に HTML ドキュメントとして出力するまでの流れを紹介します。
本記事では、主に以下の内容を取り上げます。
- build 層で解析結果をどのように統合するのか
- output 層で API 仕様として読みやすい形へ変換する方法
- プロトタイプとして実現できたことと、静的解析ならではの課題
ここまでで、設計編から続けて紹介してきた API バリデーションドキュメント生成ツールの全体像が一通り完成します。
シリーズ記事
1. Step3: build
前回記事では、analyze 層でバリデーション定義を解析し、ValidatorField という形で「各フィールドにどのようなバリデーションが設定されているか」を取得できるようになりました。
しかし、この情報だけでは、まだ API ドキュメントとして利用することはできません。
例えば、以下のような情報だけでは、
- どのエンドポイントに対するバリデーションなのか
- HTTP メソッドは何か
- URL は何か
といった、API 全体の情報が不足しています。
そこで Step3 の build 層では、これまでの各ステップで取得した情報を統合し、エンドポイント単位のデータへ組み立てていきます。
具体的には、
- scan 層で取得したルート情報
- analyze 層で生成した
ValidatorField - ルーターの basePath
を組み合わせ、最終的な Endpoint を構築します。
route
basePath
ValidatorField
↓
Endpoint
ここで重要なのは、build 層は新たにコードを解析するレイヤではない、という点です。
このレイヤの役割は、各ステップで得られた解析結果を統合し、「API として扱える単位」へ整理することにあります。
以降では、この Endpoint がどのように組み立てられているのかを見ていきます。
1-1. endpointBuilder.ts
Endpoint の生成は、endpointBuilder.ts で行っています。
実装自体はシンプルで、各ステップで取得した情報をまとめてオブジェクトを組み立てるだけです。
return {
method: method.toUpperCase(),
path: `${basePath}${relativePath}`,
validators:
mergedValidators.length > 0
? mergedValidators
: ("No validation rules" as const),
};
それぞれの値は、以下のような役割を持っています。
| フィールド | 説明 |
|---|---|
| method | HTTP メソッド |
| path | basePath とルートパスを結合したエンドポイント |
| validators | analyze 層で生成したバリデーション情報 |
例えば、以下のようなルート定義があった場合を考えてみます。
app.use("/tasks", taskRouter);
taskRouter.post("/", createTaskBody, createTask);
analyze 層では、createTaskBody を解析し、ValidatorField が生成されています。
build 層では、そこへルート情報を加えることで、
{
"method": "POST",
"path": "/tasks",
"validators": [
{
"field": "title",
"rules": [
{
"type": "required",
"message": "title is required"
},
{
"type": "length",
"min": 1,
"max": 200,
"message": "title must be 1–200 characters"
}
]
},
{
"field": "description",
"rules": [
{
"type": "optional"
},
{
"type": "length",
"max": 2000,
"message": "description must be at most 2000 characters"
}
]
},
... // 以下省略
]
}
という 1 つの Endpoint を構築します。
ここで生成された Endpoint は、「コードの解析の結果」ではなく、「API 仕様として扱えるデータ」です。
この後の output 層では、この Endpoint を入力として HTML を生成していきます。
1-2. この構成にした理由
endpointBuilder では、AST を辿ったり、型情報を取得したりといった解析処理は行っていません。
それらはすべて前段の analyze 層で完了しています。
そのため、このファイルが行っていることは、
- エンドポイントの情報をまとめる
- 出力しやすい形へ整理する
という組み立て処理だけです。
処理内容自体は多くありませんが、ここで Endpoint という共通のデータ構造を作ることで、後続の output 層は解析処理を意識することなく、HTML の生成だけに集中できるようになります。
2. Step4: output ― API 仕様として出力する
Step3 で Endpoint が完成したことで、API の情報はエンドポイント単位に整理されました。
しかし、この時点ではまだプログラムが扱いやすいデータ構造であり、人がそのまま読むには適していません。
そこで Step4 の output 層では、Endpoint をもとに HTML ドキュメントを生成し、API の仕様として読みやすい形へ変換していきます。
ここで行っているのは、新たな解析ではありません。
build 層で組み立てた Endpoint を利用し、どのように表示するかを決定することだけです。
以降では、HTML を生成する流れと、「コード」ではなく「仕様」として表現するための工夫について見ていきます。
2-1. htmlGenerator.ts
HTML の生成は、htmlGenerator.ts が担当しています。
基本的な流れはシンプルで、Endpoint を順番に処理し、各エンドポイントの情報を HTML へ変換していきます。
例えば、renderValidators() では、各フィールドのルールを繰り返し処理し、表形式で出力しています。
ここで扱っているのは、
- エンドポイント
- フィールド名
- バリデーションルール
といった、build 層で組み立てられたデータだけです。
そのため、このファイルには
- AST の走査
- import の解決
- 条件分岐の評価
といった解析処理は登場しません。
必要な情報はすべて Endpoint にまとまっているため、output 層は「どのように表示するか」だけに集中できます。
2-2. 「コード」を「仕様」へ変換する
HTML を生成するだけであれば、Endpoint に格納された情報をそのまま表示することもできます。
しかし、それでは express-validator のメソッドチェーンが並ぶだけになり、非エンジニアにとっては読みやすいドキュメントとは言えません。
例えば、次のようなバリデーションがあったとします。
body("title").isLength({ min: 1, max: 200 });
これをそのまま表示するのではなく、
1文字以上200文字以内
というように、人が理解しやすい表現へ変換しています。
function formatLength(rule: Rule) {
const { min, max } = rule;
if (min != null && max != null) {
return `${min}文字以上、${max}文字以内`;
}
if (min != null) {
return `${min}文字以上`;
}
if (max != null) {
return `${max}文字以内`;
}
return;
}
このような変換をルールごとに用意することで、実装として記述されたバリデーションを、API の仕様として読める形へ置き換えています。
function renderValidators(
validators: ValidatorField[] | "No validation rules"
) {
return `
<table>
<thead>
<tr>
... // 省略
</tr>
</thead>
<tbody>
${validators
.map((v) => {
const rules = v.rules
.map((r) => {
if (r.type === "length") {
return formatLength(r);
}
... // 以下省略
return null;
})
.filter(Boolean)
.join("<br>");
return `
<tr>
<td>
<div class="field-name">${v.field}</div>
${
badge
? `<div class="badges">${badge}</div>`
: `<div class="badges">${UNKNOWN_BADGE}</div>`
}
</td>
... // 以下省略
</tr>
`;
})
.join("")}
</tbody>
</table>
`;
}
ここで意識したのは、「コードを見せる」のではなく、「コードが表現している意味を伝える」ことです。
ツールの目的である「”コードをそのまま表示する” のではなく、”コードから仕様を生成する”」を形にする箇所でもあります。
3. プロトタイプを通して見えたこと
ここまでで、API バリデーション定義から HTML ドキュメントを生成する一連の流れを実装できました。
今回作成したのはあくまでプロトタイプですが、実際に実務を意識したコードを対象に実装を進めることで、静的解析によって扱える範囲と、難しい部分の両方が見えてきました。
以下では、今回実現できたことと、今後改善が必要だと感じた点を整理します。
3-1. 実務コードを対象としたプロトタイプを実現できた
今回のプロトタイプでは、単純なサンプルコードだけでなく、実務でよく見られるようなコード構成を対象に解析できることを目標としていました。
例えば、実際のコードではバリデーションは一か所にまとまっているとは限らず、
- ルーティングから validator を特定する
- validator が関数として抽象化されている
- 定数や型情報を参照している
- 条件によって適用されるルールが変わる
といった構造が一般的です。
今回の実装では、それらを段階的に解決することで、最終的にエンドポイント単位の HTML ドキュメントを生成できるところまで実現できました。
もちろん、すべての実装パターンを扱えるわけではありません。
しかし、実務を意識したコード構成に対しても、「コードを正として仕様を生成する」という今回のアプローチが十分成立することを確認できたのは、大きな収穫だったと感じています。
3-2. 今回扱わなかったもの
一方で、静的解析だけでは扱うことが難しいケースもありました。
■ custom()
もっとも代表的なのが、custom() による任意のバリデーションです。
const dueDateField = () =>
body("dueDate")
.optional({ values: "null" })
.custom((value) => {
if (value === null || value === undefined) return true;
if (typeof value !== "string") return false;
const t = Date.parse(value);
return !Number.isNaN(t);
})
.withMessage("dueDate must be ISO 8601 date string or null");
このようなコードでは、バリデーションの内容が自由に実装できるため、構文だけを見ても「どのようなルールなのか」を一般化することができません。
そのため、今回は custom() が存在することまでは取得しつつ、その中身までは解析対象としていません。
■ import の解析
validator の import についても、今回は正規表現による解析を採用しました。
プロトタイプとしては十分でしたが、別名 import や namespace import など、より多様な記述形式を考えると、実用化に向けては AST を利用した解析へ置き換える余地があります。
■ 条件分岐
条件分岐についても、今回は boolean の引数を前提とした比較的単純なケースを対象としました。
より複雑な条件式や実行時の値に依存する分岐まで扱おうとすると、静的解析だけでは判断できないケースが増えてきます。
どこまでを静的解析で扱い、どこからを対象外とするのかは、ツールとしての設計方針にも関わる部分だと感じました。
おわりに
今回のシリーズでは、ast-grep と ts-morph を組み合わせ、API バリデーション定義からドキュメントを生成するプロトタイプを作成しました。
当初は「AST を扱ってみたい」という興味から始めた取り組みでしたが、今回のプロトタイプを通して、「コードを正として仕様を生成する」というアプローチが、実務コードに対しても十分成立する可能性を感じることができました。
実際にツールを作ってみると、AST の取得だけでは十分ではなく、参照関係や型情報、条件分岐などを組み合わせて初めて実用的な解析に近づけることも実感しました。
今回はプロトタイプという位置付けのため、対応できていないケースも多くあります。しかし、このプローチには大きな可能性を感じています。
今後は解析精度の向上や対応ケースの拡充を進めながら、より実用的なツールへ発展させていきたいと考えています。
以上です。最後まで閲覧いただきありがとうございます。