🏭

GraphQLとコードジェレータでスキーマファーストな開発

2022/01/31に公開約8,500字

フロントエンドとバックエンドを結合テスト! ...動かない;;

みたいな手戻りは最小限に抑え、効率的な開発を行っていきましょう。

この記事では、GraphQL のスキーマ定義からコード生成してフロントエンドとバックエンドを実装するスキーマファーストな開発について紹介しようと思います。

また、この記事で紹介するコードは以下のリポジトリに置いてます。

https://github.com/akhrszk/schema-first-development-with-graphql

技術スタック

TypeScript

バックエンド、フロントエンド共に TypeScript で実装しています。

GraphQL.js

GraphQL の JavaScript ライブラリです。

https://graphql.org/graphql-js/

React

フロントエンドはcreate-react-appで作りました。

https://create-react-app.dev/

Apollo GraphQL Client

フロントエンドの GraphQL クライアントとしてApolloを使います。
以下で紹介するGraphQL Code Generatorでスキーマから React hooks をコード生成して利用します。

https://www.apollographql.com/docs/react/

Fastify

サーバーは、Fastifyを採用しました。また、Fastify で GraphQL サーバーを作るのにmercuriusという Adapter を使います。

https://www.fastify.io/

GraphQL Code Generator

スキーマ定義からフロントエンド、バックエンドのコードを自動生成するツールです。

https://www.graphql-code-generator.com/docs/getting-started

Prisma

DB クライアントです。prisma はスキーマ定義をもとに Client を生成したり、マイグレーションを実行してテーブルを作ったり、CLI コマンドが充実しています。

https://www.prisma.io/

GraphQL のスキーマ定義

サンプルのリポジトリでは、backend/ディレクトリ下にあります。

schema.graphql
scalar DateTime

type Post {
  id: Int!
  title: String!
  content: String
  published: Boolean!
  author: User
  viewCount: Int!
  createdAt: DateTime!
}

type User {
  id: Int!
  email: String!
  name: String
}

type Query {
  allUsers: [User!]!
  draftsByUser(input: UserUniqueInput): [Post!]
  feed(q: String, sort: FeedSort, skip: Int, take: Int): [Post!]!
}

type Mutation {
  createDraft(authorEmail: String!, data: PostCreateInput!): Post
  deletePost(id: Int!): Post
  incrementPostViewCount(id: Int!): Post
  signup(data: UserCreateInput!): User
  togglePublishPost(id: Int!): Post
}

enum SortOrder {
  asc
  desc
}

input UserUniqueInput {
  email: String
  id: Int
}

input FeedSort {
  updatedAt: SortOrder!
}

input PostCreateInput {
  title: String!
  content: String
}

input UserCreateInput {
  email: String!
  name: String
}

このスキーマ定義をもとにクライアントサイドとサーバーサイドをそれぞれ実装していきます。

バックエンドの実装

graphql-codegenで Resolver の型を生成

以下の公式ドキュメントを参考に GraphQL Code Generator を使ってみます。

https://www.graphql-code-generator.com/docs/guides/graphql-server-apollo-yoga#guide

GraphQL Code Generator で自動生成するのにプラグインを指定します。今回使ったプラグインは、 @graphql-codegen/typescript @graphql-codegen/typescript-resolvers です。

設定ファイル
codegen.yml
overwrite: true
schema: "./schema.graphql"
generates:
  src/lib/generated/graphql.ts:
    plugins:
      - typescript
      - "typescript-resolvers"

以下のコマンドを実行して Resolver の型を生成します

yarn graphql-codegen --config codegen.yml

Resolver を実装

Resolver の型が生成されたら、それをもとに実装していきます。

resolvers.ts
export const resolvers: Resolvers<Context> = {
  Query: {
    allUsers: (_parent, _args, context, _info) => {
      return context.prisma.user.findMany()
    },
    ...
  },
  Mutation: {
    ...
  }
}

PrismaClient を Context を通して Resolver で受け取ります。
Context の実装は以下。

context.ts
const prisma = new PrismaClient({
  log: ['query', 'info', 'warn', 'error'],
})

export interface Context {
  prisma: PrismaClient
}

export const context: Context = {
  prisma
}

export const buildContext = async (
  _req: FastifyRequest,
  _reply: FastifyReply
): Promise<Context> => {
  return { prisma }
}

サーバーは Fastify を使いました。Fastify で GraphQL サーバーを実装するのにmercuriusという Adapter を使います。
上で実装したcontextresolversも一緒に渡します。

server.ts
import { resolvers } from './resolvers'
import { buildContext } from './context'

// Load schema from the file
const schema = loadSchemaSync(join(__dirname, '../schema.graphql'), {
  loaders: [new GraphQLFileLoader()]
})

// Add resolvers to the schema
const schemaWithResolvers = addResolversToSchema({
  schema, resolvers
})

const fastify = Fastify()

fastify.register(mercurius, {
  schema: schemaWithResolvers,
  context: buildContext,
  graphiql: true
})

fastify.listen(4000, '0.0.0.0', (err, addr) => {
  if (err) {
    console.error(err)
    process.exit(1)
  }
  console.info(`Server listening on ${addr}`)
})

あっという間に GraphQL サーバーが実装出来ちゃいましたね

server.ts を実行すると、GraphQL サーバーが立ち上がります。

GraphiQL も立ち上がる設定にしてあるので、ブラウザで以下の URL で開きます

http://localhost:4000/graphiql

フロントエンドの実装

フロントエンドの実装を始める前に、モックサーバーを立ち上げてみます。
ApolloServer を使うことで GraphQL のスキーマからお手軽にモックサーバーを立ち上げられます。

スキーマ定義からモックサーバーを立ち上げ

サンプルのリポジトリでは frontend/mock/ディレクトリ下にあります。

server.ts
// Load schema from the file
const schema = loadSchemaSync(join(__dirname, '../../server/schema.graphql'), {
  loaders: [new GraphQLFileLoader()]
})

const server = new ApolloServer({
  schema,
  mocks: true
})

server.listen().then(({ url }) =>
  console.log(`🚀 Server ready at ${url}`)
)

server.ts を実行したら、ブラウザで以下の URL から ApolloStudio を開けます。

http://localhost:4000

graphql-codegenで Apollo クライアント生成

バックエンドと同じように GraphQL Code Generator でコード生成します。
以下の公式ドキュメントに従いながら進めました。

https://www.graphql-code-generator.com/docs/guides/react#typed-hooks-for-apollo-and-urql

使ったプラグインは @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo です。

@graphql-codegen/typescript-react-apollo は ApolloClient の React hooks を生成するプラグインです。
今回は使用しませんでしたが、hooks 以外にも Component や HOC なども生成出来たり、その他の設定も豊富に用意されてあり、便利そうです。詳しくは公式のドキュメントを参照。

https://www.graphql-code-generator.com/plugins/typescript-react-apollo
設定ファイル
codegen.yml
overwrite: true
schema: "../backend/schema.graphql"
documents: "graphql/**/*.graphql"
generates:
  src/graphql/types.ts:
    plugins:
      - "typescript"
      - "typescript-operations"
      - "typescript-react-apollo"
documents

documents は、リクエストするときに発行するクエリを定義します。
サンプルのリポジトリでは、frontend/graphql/下にあります。

post.graphql
query feed($q: String!, $take: Int, $skip: Int, $sort: SortOrder!) {
  feed(q: $q, sort: { updatedAt: $sort }, take: $take, skip: $skip) {
    id
    title
    content
    viewCount
    createdAt
    author {
      id
      name
    }
  }
}

コード生成すると、ApolloClient でリクエストするフックが生成されます

yarn graphql-codegen --config codegen.yml

GraphQL サーバーへのリクエストを実装

上のコマンドでuseFeedQueryが生成されました。
これを使って、データを取得・表示するコンポーネントを作ります。

Feed.tsx
export const Feed: React.FC<FeedProps> = ({ q, take, skip, sort }) => {
  const { loading, error, data } = useFeedQuery({
    variables: { q, take, skip, sort }
  })
  if (loading) {
    return (<div>loading...</div>)
  }
  if (error) {
    return (<div>{error.message}</div>)
  }
  const posts = data?.feed ?? []
  return (
    <div className='Feed'>
      {posts.map((post) => (
        <div className='Feed-Row'>
          <a className='Feed-Row-Title' href='/'>{post.title}</a>
          {' '}
          <span className='Post-Views'>{post.viewCount}views</span>
          <div>
            <span className='Post-Author'>author: {post.author?.name}</span>
            {' '}
            <DateTime className='Post-DateTime' datetime={post.createdAt} />
          </div>
        </div>
      ))}
    </div>
  )
}

動作チェック

こちらのサンプルでは、docker-compose で立ち上がるようになっています。

docker-compose up

ブラウザでhttp://localhost:3000を開いてみます。

localhost_3000.png

サーバーからデータを取得して表示出来ていますね!

いかがだったでしょう?コードジェネレータを活用することで型安全に開発が進められ、尚且つ大幅に実装量を減らすことが出来ましたね!

課題

以上で概ね型安全に実装出来たのですが、残念ながら完全には型安全になっていません。。。
問題は、schema.graphql で定義した custom scalar のDateTimeです。

バックエンドでは graphql-scalars というライブラリを使って resolvers で DateTimeResolver を渡してやることで、serialize してレスポンスを返してくれています。

resolvers.ts
export const resolvers: Resolvers<Context> = {
  ...,
  DateTime: DateTimeResolver,
}

一方、クライアントサイドで GraphQL Code Generator で生成された型を見てみると、DateTime が any 型になっていました。

types.ts
export type Scalars = {
  ID: string;
  String: string;
  Boolean: boolean;
  Int: number;
  Float: number;
  DateTime: any; //←これ
};

custom scalar の DateTime を deserialize して Date 型に変換する処理もコード生成して欲しいのですが、どうすればいいのか分からず。。。
現状方法ないんでしょうかね、、、?

Discussion

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