graphql-codegen を使ってGraphQLスキーマからフロントエンドのコードを自動生成してみた
はじめに
sweeep Box ではフロントエンド(NuxtJS)とBFF(GraphQLサーバ)の間に
差が出ないように、スキーマファイルからコードを自動生成し開発しています。
ソースコードを自動生成しているライブラリが便利だったので、
使い方を紹介していきます。
GraphQLサーバ は Go
で書いているので、今回はクライアント側(TypeScript
)
の自動生成を紹介します。
使用しているライブラリはgraphql-code-generator
です。
apollo-codegen
というライブラリもあったのですが、
事例が多く見つかり、設定パラメータも豊富な graphql-code-generator
を選定しました。
使い方
導入にあたって
こちらの記事を参考にさせていただきました。ありがとうございます。必要なライブラリとインストール方法は下記の通りになります・
# @graphql-codegen/cli
# @graphql-codegen/import-types-preset
# @graphql-codegen/typescript-graphql-request
# @graphql-codegen/typescript-operations
# @graphql-codegen/near-operation-file-preset
# @graphql-codegen/add
# graphql-request
$npm install -D @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request @graphql-codegen/typescript-operations
$npm install graphql-request
必要なライブラリを用意した後は
設定ファイル とスキーマファイルが必要になります
npx graphql-codegen
を実行すると TypeScript ファイルが自動で生成されます。
設定ファイル
schema: "https://raw.githubusercontent.com/marmelab/GraphQL-example/master/schema.graphql" # Graphqlのスキーマファイル
documents: "query.graphql" # クライアントから発行するクエリファイル
generates: # 生成に関するオプション
graphql/types.ts: # 生成先ファイル
plugins: # 使用するプライグイン (用途に応じる)
- typescript
- typescript-operations
- typescript-graphql-request
-
schema
フィールドには 参照するスキーマファイルのパスを記入します。
git clone
やgit submodule
で生成したディレクトリ等のローカルファイルパスを指定することも可能です。 -
documents
フィールドには 各クライアントから発行する graphqlのクエリスキーマファイルのパスを入力します。 -
今回の場合
graphql/types.ts
にファイルが生成されます。
スキーマファイル
サンプルのスキーマファイルを一部引用します。
# 全コードは紹介の都合上割愛します。
type Tweet {
id: ID!
# The tweet text. No more than 140 characters!
body: String
# When the tweet was published
date: Date
# Who published the tweet
Author: User
# Views, retweets, likes, etc
Stats: Stat
}
type User {
id: ID!
username: String
first_name: String
last_name: String
full_name: String
name: String @deprecated
avatar_url: Url
}
type Query {
Tweet(id: ID!): Tweet
}
↑の GraphQL スキーマに対して TweetIDから 内容と作者のIDと名前を取得するクエリです。
query TweetById ($id: ID!) {
Tweet(id: $id) {
id
body
date
Author {
id
username
}
}
}
生成ファイル
2つのスキーマファイルを用意した上で npx graphql-codegen
を実行すると
client/types.ts
が生成されます。
import { GraphQLClient } from 'graphql-request';
import * as Dom from 'graphql-request/dist/types.dom';
import gql from 'graphql-tag';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
Date: any;
Url: any;
};
export type Meta = {
__typename?: 'Meta';
count?: Maybe<Scalars['Int']>;
};
export type Mutation = {
__typename?: 'Mutation';
createTweet?: Maybe<Tweet>;
deleteTweet?: Maybe<Tweet>;
markTweetRead?: Maybe<Scalars['Boolean']>;
};
export type MutationCreateTweetArgs = {
body?: InputMaybe<Scalars['String']>;
};
export type MutationDeleteTweetArgs = {
id: Scalars['ID'];
};
export type MutationMarkTweetReadArgs = {
id: Scalars['ID'];
};
export type Notification = {
__typename?: 'Notification';
date?: Maybe<Scalars['Date']>;
id?: Maybe<Scalars['ID']>;
type?: Maybe<Scalars['String']>;
};
export type Query = {
__typename?: 'Query';
Notifications?: Maybe<Array<Maybe<Notification>>>;
NotificationsMeta?: Maybe<Meta>;
Tweet?: Maybe<Tweet>;
Tweets?: Maybe<Array<Maybe<Tweet>>>;
TweetsMeta?: Maybe<Meta>;
User?: Maybe<User>;
};
export type QueryNotificationsArgs = {
limit?: InputMaybe<Scalars['Int']>;
};
export type QueryTweetArgs = {
id: Scalars['ID'];
};
export type QueryTweetsArgs = {
limit?: InputMaybe<Scalars['Int']>;
skip?: InputMaybe<Scalars['Int']>;
sort_field?: InputMaybe<Scalars['String']>;
sort_order?: InputMaybe<Scalars['String']>;
};
export type QueryUserArgs = {
id: Scalars['ID'];
};
export type Stat = {
__typename?: 'Stat';
likes?: Maybe<Scalars['Int']>;
responses?: Maybe<Scalars['Int']>;
retweets?: Maybe<Scalars['Int']>;
views?: Maybe<Scalars['Int']>;
};
export type Tweet = {
__typename?: 'Tweet';
Author?: Maybe<User>;
Stats?: Maybe<Stat>;
body?: Maybe<Scalars['String']>;
date?: Maybe<Scalars['Date']>;
id: Scalars['ID'];
};
export type User = {
__typename?: 'User';
avatar_url?: Maybe<Scalars['Url']>;
first_name?: Maybe<Scalars['String']>;
full_name?: Maybe<Scalars['String']>;
id: Scalars['ID'];
last_name?: Maybe<Scalars['String']>;
/** @deprecated Field no longer supported */
name?: Maybe<Scalars['String']>;
username?: Maybe<Scalars['String']>;
};
export type TweetByIdQueryVariables = Exact<{
id: Scalars['ID'];
}>;
export type TweetByIdQuery = { __typename?: 'Query', Tweet?: { __typename?: 'Tweet', id: string, body?: string | null | undefined, date?: any | null | undefined, Author?: { __typename?: 'User', id: string, username?: string | null | undefined } | null | undefined } | null | undefined };
export const TweetByIdDocument = gql`
query TweetById($id: ID!) {
Tweet(id: $id) {
id
body
date
Author {
id
username
}
}
}
`;
export type SdkFunctionWrapper = <T>(action: (requestHeaders?:Record<string, string>) => Promise<T>, operationName: string) => Promise<T>;
const defaultWrapper: SdkFunctionWrapper = (action, _operationName) => action();
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
return {
TweetById(variables: TweetByIdQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise<TweetByIdQuery> {
return withWrapper((wrappedRequestHeaders) => client.request<TweetByIdQuery>(TweetByIdDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'TweetById');
}
};
}
export type Sdk = ReturnType<typeof getSdk>;
自動生成されたファイルからgetSdk
が提供されるので、
graphql-request
のクライアントクラスを引数に渡すと
戻り値で TweetByIdDocument
が実行できるようになります。
import { GraphQLClient } from 'graphql-request'
import { getSdk } from "./types.ts"
const BASE_GRAPHQL_ENDPOINT = "http://127.0.0.1:xxxx/graphql"
const graphQLClient = new GraphQLClient(BASE_GRAPHQL_ENDPOINT)
const sampleClient = getSdk(graphQLClient)
カスタマイズ
便利なパラメータを紹介していこうと思います。
パラメータの一覧はこちらになります。
schema: "https://raw.githubusercontent.com/marmelab/GraphQL-example/master/schema.graphql"
generates:
./client/types.ts:
plugins:
- typescript
-
plugins
をtypescript
のみにすることで、Schemaで定義されている型だけ生成することが
可能です。 -
複数リソースを扱う場合は複数のファイルを生成することもできます。
クエリスキーマを参照する際は↓プラグインを使用します。
typescript-operations
typescript-graphql-request
skipTypename
を 設定すると types.ts
に生成された定義と重複を避けることができます。
preset: import-types
presetConfig:
typesPath: ./types
の設定で型ファイルが types
から自動インポートされます。
まとめると以下のようになります。
schema: "https://raw.githubusercontent.com/marmelab/GraphQL-example/master/schema.graphql"
generates:
./client/types.ts:
plugins:
- typescript
./client/tweet.ts:
documents: "generate-code/queries/sample.graphql"
plugins:
- typescript-operations
- typescript-graphql-request
config:
skipTypename: true
scalars:
Time: string
preset: import-types
presetConfig:
typesPath: ./types
ハマった点
生成する度にlinter
の警告 + precommit
でエラーがしまい
手で修正する必要があるというトイルが発生していました。
対策としては以下のようになります。
-
overwrite: true
で全て再度作成する -
add
プラグインを使って全ての生成ファイルに先頭にコメントやimport
を記載する -
hooks
を使って実行後に 特定のコマンド- prettier --write
を実行する
で解消しました。ライブラリの柔軟さに驚愕しました。
overwrite: true
schema: "https://raw.githubusercontent.com/marmelab/GraphQL-example/master/schema.graphql"
definitions:
add: &top-comment
content: >
/**
* NOTE: THIS IS AN AUTO-GENERATED FILE. DO NOT MODIFY IT DIRECTLY.
*/
/* eslint-disable */
generates:
./client/tweet.ts:
documents: "generate-code/queries/sample.graphql"
plugins:
- typescript-operations
- typescript-graphql-request
- add:
content: "import { TCustomScalar } from './customScalars'"
config:
skipTypename: true
scalars:
Time: string
preset: import-types
presetConfig:
typesPath: ./types
hooks:
afterOneFileWrite:
- prettier --write
結び
自動生成されたコードを使用することで開発速度の向上 + バグの発生確率減少を実現しました。
特にフロントエンド開発では良いUXの提供に集中した方がよいと思っているので、サーバとの通信周りのコード量削減をできるのということはとても開発者体験が良いと思いました。
またライブラリ特有のとっつきにくさも最初はあったものの、比較的わかりやすく柔軟性が高いライブラリという印象です。
最後に
弊社では本質に向き合うための効率・自動化に力を注いでいます。
スキーマ駆動開発・コード自動生成もその一環です。
プロダクト作成だけでなく開発者体験の向上に興味あるエンジニアの方
是非一緒に働きましょう!
Discussion