🌊

graphql-codegen を使ってGraphQLスキーマからフロントエンドのコードを自動生成してみた

2022/03/25に公開約9,300字

はじめに

sweeep Box ではフロントエンド(NuxtJS)とBFF(GraphQLサーバ)の間に
差が出ないように、スキーマファイルからコードを自動生成し開発しています。
ソースコードを自動生成しているライブラリが便利だったので、
使い方を紹介していきます。
GraphQLサーバ は Go で書いているので、今回はクライアント側(TypeScript)
の自動生成を紹介します。
使用しているライブラリはgraphql-code-generatorです。

https://www.graphql-code-generator.com/

apollo-codegen というライブラリもあったのですが、
事例が多く見つかり、設定パラメータも豊富な graphql-code-generator
を選定しました。

https://github.com/tgriesser/apollo-codegen

使い方

導入にあたって

https://techlife.cookpad.com/entry/2021/03/24/123214
こちらの記事を参考にさせていただきました。ありがとうございます。

必要なライブラリとインストール方法は下記の通りになります・

# @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 clonegit submodule で生成したディレクトリ等のローカルファイルパスを指定することも可能です。

  • documents フィールドには 各クライアントから発行する graphqlのクエリスキーマファイルのパスを入力します。

  • 今回の場合 graphql/types.ts にファイルが生成されます。

スキーマファイル

https://raw.githubusercontent.com/marmelab/GraphQL-example/master/schema.graphql

サンプルのスキーマファイルを一部引用します。

//raw.githubusercontent.com/marmelab/GraphQL-example/master/schema.graphql
# 全コードは紹介の都合上割愛します。
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)

カスタマイズ

便利なパラメータを紹介していこうと思います。
パラメータの一覧はこちらになります。

https://www.graphql-code-generator.com/plugins/typescript-operations
schema: "https://raw.githubusercontent.com/marmelab/GraphQL-example/master/schema.graphql"
generates:
  ./client/types.ts:
    plugins:
      - typescript
  • pluginstypescriptのみにすることで、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 でエラーがしまい
手で修正する必要があるというトイルが発生していました。

対策としては以下のようになります。

  1. overwrite: true で全て再度作成する
  2. add プラグインを使って全ての生成ファイルに先頭にコメントやimportを記載する
  3. 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の提供に集中した方がよいと思っているので、サーバとの通信周りのコード量削減をできるのということはとても開発者体験が良いと思いました。
またライブラリ特有のとっつきにくさも最初はあったものの、比較的わかりやすく柔軟性が高いライブラリという印象です。

最後に

弊社では本質に向き合うための効率・自動化に力を注いでいます。
スキーマ駆動開発・コード自動生成もその一環です。
プロダクト作成だけでなく開発者体験の向上に興味あるエンジニアの方
是非一緒に働きましょう!

https://corp.sweeep.ai/recruit
GitHubで編集を提案

Discussion

ログインするとコメントできます