あ、しんのきです

趣味とか技術系とか

続・ Hasura Allow List について(リスト管理の自動化)

しんのきです。

前回の記事で Hasura Allow List についてのの運用方法を考察しました。

blog.sinki.cc

次に世代管理ですが、 v1 v2 のようなバージョン管理だと崩壊しそうなので日付ベースの名前をつけたほうがよさそうです。

そこで使わなくなったオペレーションは <クライアント名>/<ロール名>/<オペレーション名>~<使わなくなった日時> のように名前を変更するとよさそうです。

命名規則については考察できたのですが、 Allow List を管理するために追加で手作業が発生してしまうため、どうしても開発スピードが落ちたりヒューマンエラーが発生しそうで導入を足踏みしておりました。

しかし先日の GraphQL Tokyo Meetup #10 にて Codegen の自作に関する LT に影響を受け、 yaml ファイルで管理される Allow List の metadata を自動生成するという着想を得たのでやってみました。

Meetup の様子は全て YouTubeアーカイブとして残っているのでぜひ御覧ください!
(しんのきもスピーカーとして参加しました)

https://youtu.be/OoWDQeYBWQY

Hasura が生成する metadata ファイル

Hasura は v1.2.0 以降で導入された config v2 から、データベースのマイグレーションmigrations ディレクトリに .sql ファイルとして、それ以外の Hasura に関する設定は metadata ディレクトリに .yaml.graphql ファイルとして分けて出力されるようになりました。
(config v1 では全て同じディレクトリ内にあったので、 v2 になって分かりやすくなりパフォーマンスも向上しました)

Allow List に関係するのはmetadata/allow_list.yamlmetadata/query_collections.yaml という 2 つのファイルです。

metadata/allow_list.yaml は以下の内容で基本的に更新されることはありません。

- collection: allowed-queries

実際に Allow List に登録されたクエリは以下のような内容で metadata/query_collections.yaml に保存されていきます。

- name: allowed-queries
  definition:
    queries:
      - name: Posts
        query: |
          query Posts {
            posts: post {
              id
              body
              created_at
            }
          }
      - name: CreatePost
        query: |
          mutation CreatePost($object: post_insert_input!) {
            insert_post_one(object: $object) {
              id
              body
              created_at
            }
          }

metadata/query_collections.yaml は Allow List だけでなくクエリを保存しておく全般の用途で使われるようですが、現状で Allow List 以外に使われることがあるのかは不明です。

この metadata/query_collections.yaml を自動生成することができればかなり楽ができそうです。

(失敗した方法)GraphQL Code Generator の独自プラグインを実装する

現在 Hasura を採用している今のプロジェクトでは GraphQL Code Generator を使っているのですが、 GraphQL Code Generator では Custom Plugin として自分で書いたコードを差し込むことができるので、まずはこの仕組みに乗っかることができないか試してみました。

graphql-code-generator.com

ドキュメントに書いてある通りにやってみたところ、かなり簡単に Custom Plugin を実装できました。

実際に yaml を返すプラグインgenerate-allow-list.js という名前で以下のように実装しました。(yaml パッケージを利用しています)

const path = require("path");
const YAML = require("yaml");

module.exports = {
  plugin: (schema, documents, config, info) =>
    YAML.stringify([
      {
        name: "allowed-queries",
        definition: {
          queries: documents.map((doc) => ({
            // doc.location が絶対パスとして入ってくるので相対パスに直し
            // 拡張子を削除してクエリ名に使う
            name: path.relative(__dirname, doc.location).replace(/\.graphql$/, ""),
            // query には生のクエリを渡す
            query: doc.rawSDL,
          })),
        },
      },
    ]),
};

codegen.yaml で以下のように指定し実装した Custom Plugin が動くように設定します。

overwrite: true
schema: "http://localhost:8080/v1/graphql"
documents: "**/*.graphql"
generates:
  hasura/metadata/query_collections.yaml:
    - generate-allow-list.js

非常にスッキリ書けたのですが、実際に試しているうちに GraphQL Code Generator では同名の名前付き GraphQL オペレーションを複数読み込めないということが分かりました。

前回の記事で見たように Allow List を運用していくには同名のオペレーションを複数保存していって世代管理していきたかったので、これでは要件を満たすことができませんでした。

globfs を使って愚直にファイルを読み込む

もはや GraphQL の Codegen とは関係がなくなってしまっていますが、今回は GraphQL オペレーションの内容には興味がなくファイルの中身だけとれればいいので、 globfs を使って必要なファイルを読み込むだけでも十分使えるものが実装できました。

このときの generate-allow-list.js は以下のようになります。

const fs = require("fs");
const glob = require("glob");
const YAML = require("yaml");

// クエリリストを生成
const paths = glob.sync("**/*.graphql");
const queries = paths.map((path) => ({
  // 拡張子を削除してクエリ名に使う
  name: path.replace(/\.graphql$/, ""),
  // query にはファイルの中身をそのまま渡す
  query: fs.readFileSync(path, "utf8").toString(),
}));

// メタデータ保存
fs.writeFileSync(
  "hasura/metadata/query_collections.yaml",
  YAML.stringify([{ name: "allowed-queries", queries }])
);

GraphQL Code Generator とは関係ないので、 codegen コマンド時に実行されるように package.jsonscripts を修正する必要があります。
(GraphQL Code Generator の Lifecycle Hooks に設定してもいいかもしれません)

{
  ...
  "scripts": {
    "codegen": "graphql-codegen && node generate-allow-list.js",
  }
}

Allow List の世代管理を実装する

fs を使えば更新前の metadata/query_collections.yaml の内容を取得して比較することもできるので、ちょっと頑張って前回考察した命名規則を使った世代管理についても自動化できるように実装しました。

そこで使わなくなったオペレーションは <クライアント名>/<ロール名>/<オペレーション名>~<使わなくなった日時> のように名前を変更するとよさそうです。

最終的な generate-allow-list.js は以下のようになりました。

const fs = require("fs");
const glob = require("glob");
const YAML = require("yaml");
const dayjs = require("dayjs");

const GLOB_PATTERN = "**/*.graphql";
const METADATA_PATH = "hasura/metadata/query_collections.yaml";

// クエリリストを生成
const paths = glob.sync(GLOB_PATTERN);
const queries = paths.map((path) => ({
  // 拡張子を削除してクエリ名に使う
  name: path.replace(/\.graphql$/, ""),
  // query にはファイルの中身をそのまま渡す
  query: fs.readFileSync(path, "utf8"),
}));

// 現在のクエリリストを取得
const currentQueries = YAML.parse(fs.readFileSync(METADATA_PATH, "utf8")).find(
  (item) => item.name === "allowed-queries"
).definition.queries;

// 互換用クエリリストを生成
const timestamp = dayjs().format("YYYYMMDDHHmmss");
const compatQueries = currentQueries.flatMap((q) =>
  queries.some(({ query }) => query === q.query)
    ? // 同じ内容のクエリがあれば無視する
      []
    : // クエリ名にタイムスタンプが付いていなければ付与して保存
      [
        {
          name: q.name.match(/~\d+$/) ? q.name : `${q.name}~${timestamp}`,
          query: q.query,
        },
      ]
);

// クエリリストをマージして名前順にソートし直す
const mergedQueries = [...queries, ...compatQueries].sort((q1, q2) =>
  q1.name.localeCompare(q2.name)
);

// メタデータ保存
fs.writeFileSync(
  METADATA_PATH,
  YAML.stringify([
    {
      name: "allowed-queries",
      definition: {
        queries: mergedQueries,
      },
    },
  ])
);

この状態でメタデータを生成し hasura metadata apply するといい感じに Allow List に反映できました。

一覧性もよく管理しやすそうです。

f:id:konoki_nannoki:20200830160134p:plain

開発していると古いクエリがどんどん溜まっていくので、フロントエンドの更新が行き渡り完全に使われることが無くなってから metadata/query_collections.yaml から手動で取り除くことで完全に削除されます。

まとめ

ようやく開発効率を落とさずに Allow List を導入できそうです!

Hasura Cloud を使うと Allow List 機能が強化されブロックしたオペレーションを確認してその場で Allow List に登録できるようになりますが、それでもデプロイ直後のエラーは避けられないので今回のやり方は有用かと思われます。

普通に使っていると Allow List の管理は面倒だと思うのですが、現実的な運用方法に関する情報がどこにも転がっていなく、ここまでたどり着くまでだいぶ苦戦しました。
これでしばらく運用してみて、良さそうだったら npm パッケージとして公開したり英語の記事も書きたいですね。