ソースコード内の GraphQL DocumentNode を解析するために ts-morph に入門してみた
背景
デジちゃいむ では Hasura を採用しており、セキュリティ対策として Allow List によって所定の GraphQL リクエストのみを許可するような設定をしています。
この Allow List を生成するために GraphQL Code Generator の @graphql-codegen/hasura-allow-list プラグインを使用していますが、クライアント側で TypeScript のコード生成に使用している @graphql-codegen/typed-document-node と生成の微妙に挙動が異なり[1]、 Allow List と一致しなくなり弾かれてしまうというインシデントが度々発生していました。
そこで TypeScript のコードと Allow List を別々に生成するのではなく、 TypeScript のコードを元に Allow List を生成すれば挙動の違いに悩まされることは原理的になくなると考えました。[2]
具体的には @graphql-codegen/typed-document-node の生成結果に含まれる以下のような DocumentNode をオブジェクトとして取得し、 Allow List で使用できるような YAML に変換するということがやりたいことになります。
export const FooDocument = { ... } as unknown as DocumentNode<FooQuery, FooVariables>;
オブジェクトが export されているので一見するとただ import すればいいじゃないかと思いますが、 monorepo で複数のプロジェクトにまたがっているため import の解決が難しく、また複数ある export の中から DocumentNode のみを抽出する必要があったため、今回は TypeScript Compiler API のラッパーライブラリである ts-morph を使用してコードの解析を行いました。
やったこと
ファイル読み込み
ts-morph でコードを解析するには、まず Project を作成してそこに SourceFile を追加する必要があります。
今回は各パッケージ内の __generated__/graphql-operations.ts という場所に @graphql-codegen/typed-document-node の結果を置いていたため、以下のようなコードで SourceFile を追加しました。
project.addSourceFilesAtPaths() は project に SourceFile を追加すると同時に、追加された SourceFile の配列を返り値としても返します。
const project = new Project();
const sourceFiles = project.addSourceFilesAtPaths("apps/**/__generated__/graphql-operations.ts");
全ての export const を列挙する
SourceFile が取得できたら、 ファイル内の export const を getVariableStatements() を使って以下のように列挙しました。(色々な書き方があると思います)
sourceFiles.forEach((sourceFile) => {
const statements = sourceFile
.getVariableStatements()
.filter((v) => v.hasExportKeyword() && v.getDeclarationKind() === "const");
});
Variable Statements と Variable Declaration があり違いが分かりづらいですが、ドキュメントによると例えば次のようなコードのとき Variable Statements は文全体、 Variable Declaration は var1, var2 それぞれの宣言を指すようです。
export const var1 = "5", var2 = "6";
変数宣言から ObjectLiteralExpression を取得
変数宣言が取得できたので、そこで宣言されている値を取得するためには子ノードを見ていけばよいのですが、今回あるような as のようなキーワードもひとつのノードとなるため、思ったよりもネストが深くなります。
今回は取得したい対象がオブジェクトの宣言(Object Literal)であるということが分かっているため、 getFirstDescendantByKind() を使って ObjectLiteralExpression を取得しました。
statements.forEach((statement) => {
const expression = statement.getFirstDescendantByKind(SyntaxKind.ObjectLiteralExpression);
});
なお getFirstDescendantByKind() は対象のノードが見つからなかったときに undefined を返すので、返り値の型は T | undefined になります。
undefined を返す代わりに例外を投げたい場合は getFirstDescendantByKindOrThrow() を使うことができ、こちらは返り値の型が T になります。
ObjectLiteralExpression からオブジェクトを取得
オブジェクトの ObjectLiteralExpression が取得できたので実際に宣言されているオブジェクトを取得したいのですが、愚直にやると getProperties() を再帰的に見ていく必要があるためこれも意外と大変です。(よく調べればいい方法が提供されているような気がします)
今回取得したい DocumentNode のオブジェクトは変数やコメントを持たない純粋な JSON として扱えることが分かっていたため、かなり手抜きですが以下のような方法でパースしました。
function parseObjectLiteralExpression(expression: ObjectLiteralExpression) {
try {
return JSON.parse(expression.print());
} catch {
return null;
}
}
欲しいオブジェクトが取得できたため、元々実装していた YAML への変換処理に渡したところ、想定通りに Allow List が生成できました 🎉
完走した感想
ts-morph は機能が膨大かつ、ドキュメントにある Example も断片的なため、最初の取っ掛かりで苦労しました。
ts-node の REPL モード(引数を渡さないで実行)なども使いながらファイル読み込みやノード取得を試行錯誤し、欲しいノードが取得できたらその後はなかなかに楽しく触ることができました。
...と、ここまで書きましたが、今回は結局 ts-morph を使ったコードを採用しないことになりそうです。
というのも、今回修正したコードは GitHub Actions 上で CLI として実行するために @vercel/ncc を使ってコンパイルしているのですが、 ts-morph を追加したことによってコンパイル結果が 12MB ほどに肥大化してしまったからです。

今回はオブジェクトの取得も最終的に JSON.parse() を使うなど、結局あまり難しいことをしていないため、 .ts ファイルをテキストファイルとして扱って単純な正規表現で実装できそうだと後から気づきました。
ただ、 ts-morph や TypeScript Compiler API を使えると今回のようなツールを作るときに強力な武器になるため、また使えそうな場面があればいいなと思います。
Discussion