💬

graphql-eslintを使用してGraphQLの命名規則を強制するカスタムルールを作る

2023/10/25に公開

普段の業務ではApollo Clientを使用しているのですが、以前個人開発でRelayを使用したことがあります。その際に、RelayにはOperationやFragmentの命名規則を強制する機能があり、これが個人的にはかなり開発者体験が良かったです。

そこで、Apollo Clientを使っている場合でも同じことをしたいと思い、GraphQLの命名規則を強制するESLintのカスタムルールを作成しました!
今回はgraphql-eslintの使い方も踏まえて、作成したカスタムルールについて紹介したいと思います!

graphql-eslintについて

graphql-eslintとはThe Guildによって公開されているESLintのプラグインで、GraphQLのスキーマやオペレーションに対してLintを実行することができます。カスタムルールを作成するための機能も提供されており、今回は主にこちらの機能を使用します。

https://the-guild.dev/graphql/eslint/docs

命名規則に関するルールは存在していて、簡単な命名規則であればこのルールで設定可能です。今回実現したかった命名規則では、ファイル名やスキーマに依存する部分があり、既存のルールでは実現できなかったのでカスタムルールを作成することにしました。

https://github.com/dimaMachina/graphql-eslint/blob/master/packages/plugin/src/rules/naming-convention.ts

カスタムルールの作成

Relayを参考にして、命名規則は下記のように定めました。この命名規則を強制するカスタムルールを作成していきます。

  • Operation/Fragment名はモジュール名で始める
  • Operation名は種別(Query, Mutation)で終わる
  • Fragment名は_<型名>で終わる

最終的なカスタムルールの実装は下記のようになります。また、サンプルのリポジトリも作成してあるので、そちらも併せて参考にしてください。

最終的なカスタムルールの実装
import {
  requireGraphQLSchemaFromContext,
  type GraphQLESLintRule,
} from "@graphql-eslint/eslint-plugin";
import { GraphQLESTreeNode } from "@graphql-eslint/eslint-plugin/cjs/estree-converter/types";
import { NameNode } from "graphql";
import * as path from "path";

const RULE_ID = "graphql-operation-naming-convention";

const rule: GraphQLESLintRule<[], true> = {
  meta: {
    type: "suggestion",
    docs: {
      description: "Require names to follow specified conventions.",
      category: ["Operations"],
    },
    hasSuggestions: true,
    schema: [],
  },
  create(context) {
    requireGraphQLSchemaFromContext(RULE_ID, context);

    const report = (
      node: GraphQLESTreeNode<NameNode, true>,
      message: string,
      suggestedNames: string[],
    ) => {
      context.report({
        node,
        message: `${message}`,
        suggest: suggestedNames.map((suggestedName) => ({
          desc: `Rename to \`${suggestedName}\``,
          fix: (fixer) => fixer.replaceText(node as any, suggestedName),
        })),
      });
    };

    const parsedPath = path.parse(context.physicalFilename);
    const dirname = parsedPath.dir.split("/").pop() ?? "";
    const moduleName = parsedPath.name === "index" ? dirname : parsedPath.name;
    const upperCamelModuleName =
      moduleName.charAt(0).toUpperCase() + moduleName.slice(1);

    return {
      OperationDefinition(node) {
        if (!node.name) {
          context.report({
            node,
            message: "OperationDefinition should have a name",
          });
          return;
        }

        const operationName = node.name.value;

        const expectedPrefix = upperCamelModuleName;
        const expectedSuffix =
          node.operation.charAt(0).toUpperCase() + node.operation.slice(1);
        const expectedOperationName = new RegExp(
          `^${expectedPrefix}.*${expectedSuffix}$`,
        );

        if (!expectedOperationName.test(operationName)) {
          report(
            node.name,
            `Operation "${operationName}" should have prefix "${expectedPrefix}" and suffix "${expectedSuffix}"`,
            [`${expectedPrefix}${expectedSuffix}`],
          );
        }
      },
      FragmentDefinition(node) {
        const fragmentName = node.name.value;
        const gqlType = node.typeInfo().gqlType?.toString();
        if (!gqlType) {
          context.report({
            node,
            message: "FragmentDefinition should have a type",
          });
          return;
        }

        const expectedPrefix = upperCamelModuleName;
        const expectedSuffix = `_${gqlType}`;
        const expectedFragmentName = new RegExp(
          `^${expectedPrefix}.*${expectedSuffix}$`,
        );

        if (!expectedFragmentName.test(fragmentName)) {
          report(
            node.name,
            `Fragment "${fragmentName}" should have prefix "${expectedPrefix}" and suffix "${expectedSuffix}"`,
            [`${expectedPrefix}${expectedSuffix}`],
          );
        }
      },
    };
  },
};

module.exports = rule;
export default rule;

https://github.com/KazukiHayase/eslint-graphql-operation-naming-convenstion-sample

事前準備

下記の記事を参考に、ローカルに作成したカスタムルールを実行できる環境を準備します。

https://zenn.dev/nus3/articles/b2bc110efd0887442c11

ディレクトリ構成を下記のように定義しました。
カスタムルールはeslint-rules/graphql-operation-naming-convention.tsに実装して行くことになります。

.
├── eslint-rules
│   ├── dist
│   ├── tests
│   │   └── graphql-operation-naming-convention.test.ts
│   └── graphql-operation-naming-convention.ts
├── eslint.config.js
└── package.json

ビルド用のコマンドを用意して、eslint.config.jsにローカルのカスタムルールを使用する設定を追加します。

package.json
"scripts": {
    "build-eslint": "tsc eslint-rules/*.ts --outDir eslint-rules/dist --skipLibCheck",
},
eslint.config.js
const rulesDirPlugin = require("eslint-plugin-rulesdir");
rulesDirPlugin.RULES_DIR = "eslint-rules/dist";

module.exports = [
  {
    files: ["src/**/*.graphql"],
    plugins: {
      customRule: rulesDirPlugin,
    },
    rules: {
      "customRule/graphql-operation-naming-convention": "error",
    },
  },
];

これでローカルに作成したカスタムルールを使用できるようになりました。

ルールの型定義

ここからは実際にカスタムルールを実装していきます。

まずは大枠の作成です。graphql-eslintからGraphQLESLintRuleというルールの型が提供されているのでそちらを指定します。
GraphQLESLintRuleは型パラメータを2つ受け取ります。1つ目はルールのオプションの型で、2つ目はそのルールがGraphQLスキーマを必要とするかどうかです。

type GraphQLESLintRule<Options = [], WithTypeInfo extends boolean = false> = {
    meta: Omit<Rule.RuleMetaData, 'docs' | 'schema'> & {
        docs?: RuleDocsInfo<Options>;
        schema: Readonly<JSONSchema> | [];
    };
    create(context: GraphQLESLintRuleContext<Options>): GraphQLESLintRuleListener<WithTypeInfo>;
};

今回はオプションは不要で、GraphQLスキーマは必要なので、それぞれ下記のように指定します。

eslint-rules/graphql-operation-naming-convention.ts
import { type GraphQLESLintRule } from "@graphql-eslint/eslint-plugin";

const rule: GraphQLESLintRule<[], true> = {};

GraphQLスキーマが提供されているかのチェック

ここからはcreateの中身を実装して行きます。

今回作成するルールはGraphQLスキーマが必要なので、スキーマが提供されているかをチェックします。
そのためのヘルパーがgraphql-eslintから提供されているので、そちらを使用します。

import {
  requireGraphQLSchemaFromContext,
  type GraphQLESLintRule,
} from "@graphql-eslint/eslint-plugin";

const RULE_ID = "graphql-operation-naming-convention";

const rule: GraphQLESLintRule<[], true> = {
  create(context) {
    requireGraphQLSchemaFromContext(RULE_ID, context);

    return {};
  },
};

これにより、schemaオプションを設定していない場合は、下記のようなエラーが発生するようになります。

Error: Error while loading rule 'customRule/graphql-operation-naming-convention': Rule `graphql-operation-naming-convention` requires `parserOptions.schema` to be set and loaded. See https://bit.ly/graphql-eslint-schema for more info

モジュール名の取得

Operation/Fragment名がモジュール名で始まっているか確認するために、モジュール名を取得します。
対象のファイル名がindexの場合は親ディレクトリ名を、それ以外の場合は対象のファイル名をモジュール名とします。

const rule: GraphQLESLintRule<[], true> = {
   create(context) {
     // ...省略
 
     const parsedPath = path.parse(context.physicalFilename);
     const dirname = parsedPath.dir.split("/").pop() ?? "";
     const moduleName = parsedPath.name === "index" ? dirname : parsedPath.name;
     const upperCamelModuleName =
       moduleName.charAt(0).toUpperCase() + moduleName.slice(1);
 
     return {};
   },
 };

Operation名のバリデーション

本題の命名規則のバリデーションを実装していきます。

createの返り値はGraphQLESLintRuleListener型で、keyはGraphQL AST Node名、valueはそのNodeを引数とする関数のオブジェクトです。

type GraphQLESLintRuleListener<WithTypeInfo extends boolean = false> = Record<string, any> & {
    [K in keyof ASTKindToNode]?: (node: GraphQLESTreeNode<ASTKindToNode[K], WithTypeInfo>) => void;
};

返り値となるこのオブジェクトにフィールドを追加することで、該当のNodeに対する処理を実装できます。
ここではOperationに対する処理を実装したいので、OperationDefinitionというフィールドを追加して、Operation名のバリデーションを実装します。

const rule: GraphQLESLintRule<[], true> = {
  create(context) {
    // ...省略
    return {
      OperationDefinition(node) {
        if (!node.name) {
          context.report({
            node,
            message: "OperationDefinition should have a name",
          });
          return;
        }

        const operationName = node.name.value;

        const expectedPrefix = upperCamelModuleName;
        const expectedSuffix =
          node.operation.charAt(0).toUpperCase() + node.operation.slice(1);
        const expectedOperationName = new RegExp(
          `^${expectedPrefix}.*${expectedSuffix}$`,
        );

        if (!expectedOperationName.test(operationName)) {
          report(
            node.name,
            `Operation "${operationName}" should have prefix "${expectedPrefix}" and suffix "${expectedSuffix}"`,
            [`${expectedPrefix}${expectedSuffix}`],
          );
        }
      },
    };
  },
};

実行時にはgraphql-eslintが、GraphQL ASTをESTreeの構造に変換し、該当のNodeが見つかるたびに、実装した関数を実行してくれます。
そのため、扱いたいNodeを選択して、そのNodeに対する処理を実装するだけでいいので、非常に簡単にGraphQL ASTを扱うことができます。

Fragment名のバリデーション

Operation名の場合と同じく、Fragmentに対する処理を実装するために、FragmentDefinitionフィールドにバリデーションを実装します。

const rule: GraphQLESLintRule<[], true> = {
  create(context) {
    // ...省略
    return {
      OperationDefinition(node) {
          // ...省略
      },
      FragmentDefinition(node) {
        const fragmentName = node.name.value;
        const gqlType = node.typeInfo().gqlType?.toString();
        if (!gqlType) {
          context.report({
            node,
            message: "FragmentDefinition should have a type",
          });
          return;
        }

        const expectedPrefix = upperCamelModuleName;
        const expectedSuffix = `_${gqlType}`;
        const expectedFragmentName = new RegExp(
          `^${expectedPrefix}.*${expectedSuffix}$`,
        );

        if (!expectedFragmentName.test(fragmentName)) {
          report(
            node.name,
            `Fragment "${fragmentName}" should have prefix "${expectedPrefix}" and suffix "${expectedSuffix}"`,
            [`${expectedPrefix}${expectedSuffix}`],
          );
        }
      },
    };
  },
};

Operation名のバリデーションと大きく違う点はGraphQLスキーマの情報を利用している点です。
あらかじめスキーマが渡されているかのチェックは行っているので、node.typeInfo()によってそのNodeのGraphQLスキーマオブジェクトにアクセスすることができます。
今回の例では、gqlTypeでFragmentの型名を利用していますが、他の情報も利用することができます。

type TypeInformation = {
    argument: ReturnType<TypeInfo['getArgument']>;
    defaultValue: ReturnType<TypeInfo['getDefaultValue']>;
    directive: ReturnType<TypeInfo['getDirective']>;
    enumValue: ReturnType<TypeInfo['getEnumValue']>;
    fieldDef: ReturnType<TypeInfo['getFieldDef']>;
    inputType: ReturnType<TypeInfo['getInputType']>;
    parentInputType: ReturnType<TypeInfo['getParentInputType']>;
    parentType: ReturnType<TypeInfo['getParentType']>;
    gqlType: ReturnType<TypeInfo['getType']>;
};

実行結果

ESLintを実行した結果は下記になります。

対象の.graphqlファイル
src/components/TodoItem/index.graphql
# OK
query TodoListQuery {
  todos {
    id
    ...TodoList_Todo
  }
}

# NG
query TodoList {
  todos {
    id
    ...TodoList_Todo
  }
}

query TodosQuery {
  todos {
    id
    ...TodoList_Todo
  }
}
src/components/TodoItem/index.graphql
# OK
fragment TodoItem_Todo on Todo {
  title
  done
}

# NG
fragment TodoItem on Todo {
  title
  done
}

fragment Todos_Todo on Todo {
  title
  done
}
$ eslint .

/.../eslint-graphql-operation-naming-convenstion-sample/src/components/TodoItem/index.graphql
   8:10  error  Fragment "TodoItem" should have prefix "TodoItem" and suffix "_Todo"    customRule/graphql-operation-naming-convention
  13:10  error  Fragment "Todos_Todo" should have prefix "TodoItem" and suffix "_Todo"  customRule/graphql-operation-naming-convention

/.../eslint-graphql-operation-naming-convenstion-sample/src/components/TodoList/index.graphql
  10:7  error  Operation "TodoList" should have prefix "TodoList" and suffix "Query"    customRule/graphql-operation-naming-convention
  17:7  error  Operation "TodosQuery" should have prefix "TodoList" and suffix "Query"  customRule/graphql-operation-naming-convention

✖ 4 problems (4 errors, 0 warnings)

作成したカスタムルールのテスト

graphql-eslintはeslint.RuleTesterをラップしたテストランナーであるGraphQLRuleTesterも提供しており、こちらを使用することで作成したカスタムルールのテストも行えます。

基本的には正常系であればcodeを、異常系であればcodeerrorsを指定するだけでテストは実行できます。今回作成したカスタムルールではGraphQLスキーマとファイル情報も必要なので、追加でスキーマとfilenameを指定するようにしています。

import { join } from "path";
import rule from "../graphql-operation-naming-convention";
import { GraphQLRuleTester } from "@graphql-eslint/eslint-plugin";

const TEST_SCHEMA = /* GraphQL */ `
  type Query {
    todos: [Todo!]!
  }
  type Todo {
    id: Int!
  }
`;

const ruleTester = new GraphQLRuleTester({ schema: TEST_SCHEMA });
ruleTester.runGraphQLTests("graphql-operation-naming-convention", rule, {
  valid: [
    {
      filename: join(__dirname, "TodoItem.graphql"),
      code: "fragment TodoItem_Todo on Todo { id }",
    },
  ],
  invalid: [
    {
      filename: join(__dirname, "TodoItem.graphql"),
      code: "fragment TodoItem on Todo { id }",
      errors: [
        {
          message: `Fragment "TodoItem" should have prefix "TodoItem" and suffix "_Todo"`,
        },
      ],
    },
  ],
});

オプションはテストケースごとに指定することもできます。

const WITH_SCHEMA = {
  parserOptions: {
    schema: TEST_SCHEMA,
  },
};

const ruleTester = new GraphQLRuleTester();
ruleTester.runGraphQLTests("graphql-operation-naming-convention", rule, {
  valid: [
    {
      ...WITH_SCHEMA,
      filename: join(__dirname, "TodoItem.graphql"),
      code: "fragment TodoItem_Todo on Todo { id }",
    },
  ],
});

まとめ

graphql-eslintを利用することで、思った以上に簡単にGraphQLに対するカスタムルールを作成することができました!気が向いたらもう少し汎用的にして、プラグインとして公開できたらなと思います。
GraphQLに対するカスタムルールを作成したい場合は、ぜひ利用してみてください!

株式会社BuySell Technologies

Discussion