ソースコード内の 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