GraphQL Code Generatorのカスタムプラグインでauth directiveに設定した認証情報を元に関数を自動生成する
はじめに
関わっているプロダクトでauth directiveに設定した認証情報をもとにロールごとにUIを出しわけたいことがあり、Schemaから認証情報を持った関数を吐き出すカスタムプラグインを作りました。
例えば下記のようなSchemaがあるとします。
(The Guildのexampleの一部にカスタムディレクティブとMutationを追加したものです)
directive @auth(role: [Role!]!) on FIELD_DEFINITION
scalar Date
schema {
query: Query
}
type Query {
me: User!
user(id: ID!): User
allUsers: [User]
search(term: String!): [SearchResult!]!
}
type Mutation {
createUser(
input: CreateUserInput!
): CreateUserPayload! @auth(role: [ADMIN])
}
input CreateUserInput {
name: String!
}
type CreateUserPayload {
user: User
}
enum Role {
USER
ADMIN
}
interface Node {
id: ID!
}
union SearchResult = User
type User implements Node {
id: ID!
username: String!
email: String!
role: Role!
}
このSchemaのときにcreateUserのmutationを叩ける認証情報を持ったユーザーなのかチェックするcanCreateUser
関数をGraphQL Code Generatorのカスタムプラグインとして作成していきます。
これはADMINだけに表示したいボタンなどがある際に使用し、以下のような関数を想定しています。
type Role = 'ADMIN' | 'USER'
const canCreateUser = (role: Role) => ['ADMIN'].includes(role)
// 使用例
if (canCreateUser(me.role)) {
// ADMINロールのときだけ実行される処理
}
実装していく
多くのプラグインはvisitorパターンで実装されているとThe Guildに紹介されているので、それに則りvisitorパターンで実装を進めていきます。
まず基本的な形は以下になります。
import { getCachedDocumentNodeFromSchema } from '@graphql-codegen/plugin-helpers'
import { visit } from 'graphql'
import type { ASTVisitor, GraphQLSchema } from 'graphql'
export const plugin = (
schema: GraphQLSchema,
) => {
const ast = getCachedDocumentNodeFromSchema(schema)
const visitor: ASTVisitor = {}
visit(ast, visitor)
return {
content: 'content',
}
}
以上のように最終的に文字列でcontentを返すことでファイルに書き出されます。
いまnpx graphql-codegen
を実行するとcontentと記述されたファイルが生成されます。
なのでこれからcontentで返す文字列を作っていきます。
まず@auth
が指定されているフィールドの値を取得するための処理を書いていきます。
スキーマで定義されているフィールドの値を取得したいので今回使っていくKindはFieldDefinition
になります。
directive @auth(role: [Role!]!) on FIELD_DEFINITION
はカスタムディレクティブ自体の定義なので、今回は使用しません。
もしなにか処理をしたい場合はDirectiveDefinitionにて値を取得できます。
const visitor: ASTVisitor = {
FieldDefinition: () => {}
}
あとは取得できたNodeから欲しいデータを探していく作業になります。
ただ型定義が絡まり合っている箇所がいくつかあるため、型ガードをしっかり書いてあげる必要があります。
if (!node.directives) {
return undefined
}
// ex) createUser(input: CreateUserInput!): CreateUserPayload! @auth(role: [ADMIN])
const specifiedAuthNode = node.directives.find(
(d) => d.name.value === 'auth'
)
if (!specifiedAuthNode) {
return undefined
}
// ex) role: [ADMIN]
const roleArgument = specifiedAuthNode.arguments?.find(
(arg) => arg.name.value === 'role'
)
if (!roleArgument || roleArgument.value.kind !== Kind.LIST) {
return undefined
}
// ex) [ADMIN]
const roles = roleArgument.value.values.map((v) => {
if ('value' in v && typeof v.value === 'string') {
return v.value
}
return ''
})
これでroles
に許可するロールの情報'ADMIN'
を抽出できました。
あとは関数を作成していきます。
@graphql-codegen/visitor-plugin-common
のDeclarationBlock
を使うといい感じに書けます。
// PascalCaseに整形
const fieldName = node.name.value.charAt(0).toUpperCase() + node.name.value.slice(1)
// 関数名canXxxを文字列として生成
const functionName = new DeclarationBlock({})
.export()
.asKind('const')
.withName(`can${fieldName}`)
.withContent(
`(role) => ${JSON.stringify(
roles
)}.includes(role)`
).string
これで文字列を生成できたので、あとはvisitor関数の外で配列を定義し、そこにpushしてcontentからjoin('')
で返してあげればauth directiveに設定した認証情報を元に型情報を自動生成することができました。
一点気をつけたい点として、型ガードした際にreturn undefined
と統一しています。
今回は特に影響しませんがundefined
以外を返すと処理が変わってしまうためです。(参考)
以下に、ここまでで説明したコードの全体を記載しておきます。
カスタムプラグイン.ts
codegen.ts
からconfigとしてimportFromとimportNameを渡しています。
これは他のプラグインで出力させたtype Role = 'ADMIN' | 'USER'
のような型をimportさせるためです。
import { getCachedDocumentNodeFromSchema } from '@graphql-codegen/plugin-helpers'
import type { PluginFunction, Types } from '@graphql-codegen/plugin-helpers'
import {
DeclarationBlock,
wrapWithSingleQuotes,
} from '@graphql-codegen/visitor-plugin-common'
import { Kind, visit } from 'graphql'
import type { ASTVisitor, FieldDefinitionNode, GraphQLSchema } from 'graphql'
type Config = {
importFrom: string
importName: string
}
export const plugin: PluginFunction<Config, Types.ComplexPluginOutput> = (
schema: GraphQLSchema,
_documents,
config: Config
) => {
const canFunctionList: string[] = []
const ast = getCachedDocumentNodeFromSchema(schema)
const visitor: ASTVisitor = {
FieldDefinition: {
enter: (node: FieldDefinitionNode) => {
if (!node.directives) {
return undefined
}
const specifiedAuthNode = node.directives.find(
(d) => d.name.value === 'auth'
)
if (!specifiedAuthNode) {
return undefined
}
const roleArgument = specifiedAuthNode.arguments?.find(
(arg) => arg.name.value === 'role'
)
if (!roleArgument || roleArgument.value.kind !== Kind.LIST) {
return undefined
}
const roles = roleArgument.value.values.map((v) => {
if ('value' in v && typeof v.value === 'string') {
return v.value
}
return ''
})
const functionName =
node.name.value.charAt(0).toUpperCase() + node.name.value.slice(1)
canFunctionList.push(
new DeclarationBlock({})
.export()
.asKind('const')
.withName(`can${functionName}`)
.withContent(
`(role: ${config.importName}) => ${JSON.stringify(
roles
)}.includes(role)`
).string
)
return undefined
},
},
}
visit(ast, visitor)
return {
content: canFunctionList.join(''),
prepend: [
`import type { ${config.importName} } from ${wrapWithSingleQuotes(
config.importFrom
)}`,
'\n',
],
}
}
最後に
このプラグインを書いたときはGraphQLを触ったことがない状態で、「そもそもGraphQLとは?」「カスタムプラグインとは?」の状態で調べながら書いていました。
色々と調べましたがカスタムプラグインを書いた話があまり見つからず参考にできるものが既存のプラグインがメインで、今回作りたかったシンプルなものとは違い、より汎用的でより複雑な要件だったので実装を読み解くことが一番大変でした。
書き終わってみると割と書きやすかった印象を持っています。(とはいえログがうまく出せなくて苦労しました。)
カスタムプラグインと聞くと難しそうと敬遠してしまうことがありましたが、そんな僕でも問題ありませんでした。
参考
Discussion