graphql-eslintを使用してGraphQLの命名規則を強制するカスタムルールを作る
普段の業務ではApollo Clientを使用しているのですが、以前個人開発でRelayを使用したことがあります。その際に、RelayにはOperationやFragmentの命名規則を強制する機能があり、これが個人的にはかなり開発者体験が良かったです。
そこで、Apollo Clientを使っている場合でも同じことをしたいと思い、GraphQLの命名規則を強制するESLintのカスタムルールを作成しました!
今回はgraphql-eslintの使い方も踏まえて、作成したカスタムルールについて紹介したいと思います!
graphql-eslintについて
graphql-eslintとはThe Guildによって公開されているESLintのプラグインで、GraphQLのスキーマやオペレーションに対してLintを実行することができます。カスタムルールを作成するための機能も提供されており、今回は主にこちらの機能を使用します。
命名規則に関するルールは存在していて、簡単な命名規則であればこのルールで設定可能です。今回実現したかった命名規則では、ファイル名やスキーマに依存する部分があり、既存のルールでは実現できなかったのでカスタムルールを作成することにしました。
カスタムルールの作成
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;
事前準備
下記の記事を参考に、ローカルに作成したカスタムルールを実行できる環境を準備します。
ディレクトリ構成を下記のように定義しました。
カスタムルールは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
にローカルのカスタムルールを使用する設定を追加します。
"scripts": {
"build-eslint": "tsc eslint-rules/*.ts --outDir eslint-rules/dist --skipLibCheck",
},
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スキーマは必要なので、それぞれ下記のように指定します。
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ファイル
# OK
query TodoListQuery {
todos {
id
...TodoList_Todo
}
}
# NG
query TodoList {
todos {
id
...TodoList_Todo
}
}
query TodosQuery {
todos {
id
...TodoList_Todo
}
}
# 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
を、異常系であればcode
とerrors
を指定するだけでテストは実行できます。今回作成したカスタムルールでは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に対するカスタムルールを作成したい場合は、ぜひ利用してみてください!
Discussion