Chapter 05

GraphQLを使ってみよう

oubakiou
oubakiou
2022.05.18に更新

GraphQL ServerをNext.jsのAPI Routesで動かしてみよう

今まで使ってきたNext.jsのAPI Routesを利用して今度はGraphQL Serverを動かしてみましょう。本書では扱いませんがNext.jsとGraphQL Serverとを別々の独立したサーバーやコンテナで動作させる構成ももちろん可能です。

コラム:なぜGraphQLを使うのか

先ずは下記を実行してApollo Serverをインストールします。

npm install apollo-server-micro

apollo-server-micromicroというHTTPサーバー向けに調整されたApollo Serverです。(今回はNext.jsに含まれているhttpサーバーを利用するためmicroを利用するわけではありません)

インストールが終わったらGraphQL用のエンドポイントとして下記を作成しましょう。

pages/api/graphql.ts
import { ApolloServer, Config, gql } from 'apollo-server-micro'
import {
  ApolloServerPluginLandingPageGraphQLPlayground,
  ApolloServerPluginLandingPageDisabled,
} from 'apollo-server-core'
import { NextApiRequest, NextApiResponse } from 'next'

// スキーマ定義(インターフェース)
const typeDefs: Config['typeDefs'] = gql`
  type Query {
    statuses: [Status]!
  }

  type Status {
    id: String!
    body: String!
    author: String!
    createdAt: String!
  }
`

// スキーマを実際に動作させるリゾルバー(実装)
const resolvers: Config['resolvers'] = {
  Query: {
    statuses() {
      return listStatuses()
    },
  },
}
const listStatuses = (): Status[] => statuses

// ハードコーディングされたデータ
type Status = { id: string; body: string; author: string; createdAt: string }
const statuses: Status[] = [
  {
    id: '2',
    body: 'inviting coworkers',
    author: 'jack',
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    body: 'just setting up my app',
    author: 'jack',
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

// Next.jsのAPI Routeの設定
// @see https://nextjs.org/docs/api-routes/api-middlewares#custom-config
export const config = {
  api: {
    bodyParser: false,
  },
}

// Apollo Serverの生成
const isDevelopment = process.env.NODE_ENV === 'development'
const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: isDevelopment,
  plugins: [
    isDevelopment
      ? ApolloServerPluginLandingPageGraphQLPlayground()
      : ApolloServerPluginLandingPageDisabled(),
  ],
})

// 起動と登録
const startServer = apolloServer.start()
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  await startServer
  await apolloServer.createHandler({
    path: '/api/graphql',
  })(req, res)
}

作成できたら

npm run dev

で起動し

http://localhost:3000/api/graphql
へアクセスします。

これはGraphQL Playgroundというブラウザベースのクエリーエディタです。試しに下記のクエリーを入力して実行してみましょう。

{
  statuses {
    id
    body
  }
}

idとbodyの一覧が取得できたでしょうか。

それではGraphQLサーバーのコードを見てみましょう。

// スキーマ定義(インターフェース)
const typeDefs: Config['typeDefs'] = gql`
  type Query {
    statuses: [Status]!
  }

  type Status {
    id: String!
    body: String!
    author: String!
    createdAt: String!
  }
`

typeDefsはこのGraphQLサーバーが扱うスキーマの定義です。またgql関数へタグ付きテンプレートリテラルで渡されているものはGraphQL SDL(スキーマ定義言語)で記述されたスキーマ定義です。今回はQuery型としてStatus型の配列を返すstatusesというクエリーを一つ定義しています。Query型はGraphQLのシステム上特別な意味を持った予約型でGraphQLクライアントからはここで定義したクエリーを使う事になります。

SDLでの型定義でフィールドに対して!というマークがついているのは、そのフィールドが非nullである事を表現しています。またID型やString型は標準で用意されているスカラー型です。[Status]のように[]で囲まれているものは配列型を意味しています。

// スキーマを実際に動作させるリゾルバー(実装)
const resolvers: Config['resolvers'] = {
  Query: {
    statuses() {
      return listStatuses()
    },
  },
}
const listStatuses = (): Status[] => statuses

// ハードコーディングされたデータ
type Status = { id: string; body: string; author: string; createdAt: string }
const statuses: Status[] = [
  {
    id: '2',
    body: 'inviting coworkers',
    author: 'jack',
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    body: 'just setting up my app',
    author: 'jack',
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

resolversは先ほどtypeDefsで定義した各クエリーについて、それを実際に処理するコードを書きます。今回はハードコーディングされたデータを使用していますが一般的なアプリケーションでは、MySQLやFirestoreのようなDB、あるいは別のREST APIのような外部データソースから取得されたデータを返す事になります。

// Next.jsのAPI Routeの設定
// @see https://nextjs.org/docs/api-routes/api-middlewares#custom-config
export const config = {
  api: {
    bodyParser: false,
  },
}

// Apollo Serverの生成
const isDevelopment = process.env.NODE_ENV === 'development'
const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: isDevelopment,
  plugins: [
    isDevelopment
      ? ApolloServerPluginLandingPageGraphQLPlayground()
      : ApolloServerPluginLandingPageDisabled(),
  ],
})

// 起動と登録
const startServer = apolloServer.start()
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  await startServer
  await apolloServer.createHandler({
    path: '/api/graphql',
  })(req, res)
}

最後はtypeDefsやresolversを元にして実際にGraphQLサーバーを生成しているコードです。playgroundとintrospectionは明示的に開発環境でのみ有効にしています。introspectionはGraphQLクライアントなどがコードや型を生成するにあたって必要なスキーマ情報などをGraphQLサーバーから得るための機能です。

引数を受け取れるクエリーを書いてみよう

ステータスIDを受け取って対応したステータスを返すクエリーを追加してみましょう。

const typeDefs: Config['typeDefs'] = gql`
  type Query {
    statuses: [Status]!
+    status(id: ID!): Status
  }

typeDefsのQuery型にstatusというクエリーを追加します。

const resolvers: Config['resolvers'] = {
  Query: {
    statuses() {
      return listStatuses()
    },
+    status(_parent, args) {
+      return getStatus(args?.id) ?? null
+    },

resolversにもstatusクエリーと対応したリゾルバーを追加します。リゾルバーはparent, args, context, infoという4つの引数を受け取りますが今回は第2引数のargsのみを使用しています。

const listStatuses = (): Status[] => statuses
+const getStatus = (id: string): Status | undefined =>
+  statuses.find((d) => d.id === id)

最後にstatusリゾルバーへ紐付ける処理の実体を追加します。

それではPlaygroundから下記を実行してみましょう。

{
  status(id: "1") {
    id
    body
  }
}

入れ子になったオブジェクトを取得してみよう

次はオブジェクトが別のオブジェクトを持った入れ子構造を表現してみましょう。

  type Status {
    id: ID!
    body: String!
-    author: String!
+    author: Author!
    createdAt: String!
  }

+  type Author {
+    id: ID!
+    name: String!
+  }

SDLに新しくAuthor型を追加し、それをStatus型に持たせます。続けてハードコーディングされたデータを新しい構造に合わせて修正しましょう。

// ハードコーディングされたデータ
type Status = { id: string; body: string; author: Author; createdAt: string }
type Author = { id: string; name: string }
const statuses: Status[] = [
  {
    id: '2',
    body: 'inviting coworkers',
    author: {
      id: '1',
      name: 'jack',
    },
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    body: 'just setting up my app',
    author: {
      id: '1',
      name: 'jack',
    },
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

今回の例ではresolverを変更する必要はありませんが、一般的な正規化されたRDBが背後にある場合にはstatusテーブルのデータとauthorテーブルのデータとを何らかの方法で組合せたり整形したりする必要があります。ORMが助けになる場合もあるでしょう。

{
  statuses {
    id
    body
    author {
      id
      name
    }
  }
}

クエリーを実行して結果を確認してみましょう。

入れ子になったオブジェクトをリゾルバーチェーンで取得してみよう

別解としてリゾルバーチェーンという仕組みを利用して取得する方法も見てみましょう。これは複数のリゾルバを組合せる事で入れ子構造に対応する仕組みです。

今回はRDBをイメージした下記のようなデータ構造を使います。

// ハードコーディングされたデータ
type Status = { id: string; body: string; authorId: string; createdAt: string }
const statuses: Status[] = [
  {
    id: '2',
    authorId: '1',
    body: 'inviting coworkers',
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    authorId: '1',
    body: 'just setting up my app',
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

type Author = { id: string; name: string }
const authors: Author[] = [
  {
    id: '1',
    name: 'jack',
  },
]

resolversのQuery型と同レベルにStatus型のリゾルバーを書きましょう。

const resolvers: Config['resolvers'] = {
  Query: {
    statuses() {
      return listStatuses()
    }
    status(_parent, args) {
      return getStatus(args?.id) ?? null
    },
  },
+  Status: {
+    author: (parent) => {
+      return getAuthor(parent.authorId)
+    },
+  },
}

これはStatus型のオブジェクトのauthorフィールドを取得する際のリゾルバーとして機能します。また第一引数であるparentからは親要素(この場合はStatus)の実データを参照する事が出来ます。

const listStatuses = (): Status[] => statuses
const getStatus = (id: string): Status | undefined =>
  statuses.find((d) => d.id === id)
+const getAuthor = (id: string): Author | undefined =>
+  authors.find((a) => a.id === id)

リゾルバーチェーンを使うとリゾルバーの実装がシンプルになる反面、N+1問題に気をつける必要があります。仮にgetAuthorが1回SQLを発行する実装の場合、100個のStatusを取得する際には100回のgetAuthorが呼ばれ100回SQLを発行する事になります。場合によってはこれを解消するためにJOIN句やIN句を使ってSQLを書き換えるなどの対策が必要になるでしょう。

あるいはdataloaderと呼ばれる仕組みが組み合わされる事もあります。

複数種類のオブジェクトを取得するクエリーを書いてみよう

最後に、複数種類のオブジェクトを取得する例を見てみましょう。今まではStatus型を中心に扱ってきましたが、それとは独立したBanner型を新しく追加します。

  type Query {
    statuses: [Status]!
    status(id: ID!): Status
+    banners(groupId: ID): [Banner]!
  }

  type Status {
    id: ID!
    body: String!
    author: Author!
    createdAt: String!
  }

  type Author {
    id: ID!
    name: String!
  }

+  type Banner {
+    id: ID!
+    groupId: String!
+    href: String
+  }

サーバー側の実装には特に新しい知識は必要ありません。bannersクエリーのリゾルバーを実装しましょう。

const resolvers: Config['resolvers'] = {
  Query: {
    statuses() {
      return listStatuses()
    },  
    status(_parent, args) {
      return getStatus(args?.id) ?? null
    },
+    banners(_parent, args) {
+      return listBanners(args.groupId)
+    },
  },
  Status: {
    author: (parent) => {
      return getAuthor(parent.authorId)
    },
  },
}

type Status = { id: string; body: string; authorId: string; createdAt: string }
const statuses: Status[] = [
  {
    id: '2',
    authorId: '1',
    body: 'inviting coworkers',
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    authorId: '1',
    body: 'just setting up my app',
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

type Author = { id: string; name: string }
const authors: Author[] = [
  {
    id: '1',
    name: 'jack',
  },
]

+type Banner = {
+  id: string
+  groupId: string
+  href: string | null
+}
+const banners: Banner[] = [
+  {
+    id: '2',
+    groupId: '1',
+    href: null,
+  },
+  {
+    id: '1',
+    groupId: '1',
+    href: null,
+  },
+]

const listStatuses = (): Status[] => statuses
const getStatus = (id: string): Status | undefined =>
  statuses.find((d) => d.id === id)  
const getAuthor = (id: string): Author | undefined =>
  authors.find((a) => a.id === id)
+const listBanners = (groupId: string): Banner[] =>
+  banners.filter((b) => b.groupId === groupId)

それでは下記のクエリーをPlaygroundの左ペイン上に入力してみましょう。このクエリーはstatusIdとbannerGroupIdという二つの引数を受け取る事ができます。

query getStatusPageProps ($statusId: ID!, $bannerGroupId: ID!){
  status(id: $statusId) {
    id
    body
    author {
      id
      name
    }    
  },
  banners(groupId: $bannerGroupId) {
    id
    href
  }
}

さらに左ペイン下のQUERY VARIABLESタブに具体的な引数の値を入力して実行します。

QUERY VARIABLES
{
  "statusId": 1,
  "bannerGroupId": 1
}

期待した通りの結果は返ってきたでしょうか。

typeDefsとresolversを別ファイルに切り出してみよう

graphql.tsが長くなってきたのでtypeDefsとresolversを別ファイルへ分割をしましょう。またtypeDefsはTypeScriptファイルとしてではなく、純粋なSDLファイルとして.graphqlの拡張子で保持するようにします。

まずはwebpackの設定を追加するためnext.config.jsに下記を追加します。これによって拡張子がgraphqlのファイルがimport時にgraphql-tag/loaderを使って読み込まれるようになります。

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
+  // @see https://www.apollographql.com/docs/react/integrations/webpack/
+  webpack: (config) => {
+    config.module.rules.push({
+      test: /\.(graphql)$/,
+      exclude: /node_modules/,
+      loader: 'graphql-tag/loader',
+    })
+
+    return config
+  },
}

module.exports = nextConfig

新しくgraphqlディレクトリを作り下記を保存します。

graphql/graphql.d.ts
declare module '*.graphql' {
  import { DocumentNode } from 'graphql'
  const Schema: DocumentNode

  export = Schema
}

ここでは読み込まれた.graphqlファイルの中身がDocumentNode型として扱われるよう宣言しています。準備が出来たのでtypeDefsとresolversを別ファイルとして保存してみましょう。

graphql/typeDefs.graphql
type Query {
  status(id: ID!): Status
  statuses: [Status]!
  banners(groupId: ID!): [Banner]!
}

type Status {
  id: ID!
  body: String!
  author: Author!
  createdAt: String!
}

type Author {
  id: ID!
  name: String!
}

type Banner {
  id: ID!
  groupId: ID!
  href: String
}

graphql/resolvers.ts
import { Config } from 'apollo-server-micro'

// スキーマを実際に動作させるリゾルバー(実装)
export const resolvers: Config['resolvers'] = {
  Query: {
    statuses() {
      return listStatuses()
    },
    status(_parent, args) {
      return getStatus(args?.id) ?? null
    },
    banners(_parent, args) {
      return listBanners(args.groupId)
    },
  },
  Status: {
    author: (parent) => {
      return getAuthor(parent.authorId)
    },
  },
}

const listStatuses = (): Status[] => statuses

const getStatus = (id: string): Status | undefined =>
  statuses.find((d) => d.id === id)

const getAuthor = (id: string): Author | undefined =>
  authors.find((a) => a.id === id)

const listBanners = (groupId: string): Banner[] =>
  banners.filter((b) => b.groupId === groupId)

// ハードコーディングされたデータ
type Status = { id: string; body: string; authorId: string; createdAt: string }
const statuses: Status[] = [
  {
    id: '2',
    authorId: '1',
    body: 'inviting coworkers',
    createdAt: new Date(2021, 4, 2).toISOString(),
  },
  {
    id: '1',
    authorId: '1',
    body: 'just setting up my app',
    createdAt: new Date(2021, 4, 1).toISOString(),
  },
]

type Author = { id: string; name: string }
const authors: Author[] = [
  {
    id: '1',
    name: 'jack',
  },
]

type Banner = {
  id: string
  groupId: string
  href: string | null
}
const banners: Banner[] = [
  {
    id: '2',
    groupId: '1',
    href: null,
  },
  {
    id: '1',
    groupId: '1',
    href: null,
  },
]

今後resolverの実装が増えていくことを考えると、さらに分割しておいたほうが見通しが良くなりそうですが次章へ託しましょう。最後にgraphql.tsからimportします。

pages/api/graphql.ts
import { ApolloServer } from 'apollo-server-micro'
import {
  ApolloServerPluginLandingPageGraphQLPlayground,
  ApolloServerPluginLandingPageDisabled,
} from 'apollo-server-core'
import { NextApiRequest, NextApiResponse } from 'next'
import typeDefs from 'graphql/typeDefs.graphql'
import { resolvers } from 'graphql/resolvers'

// Next.jsのAPI Routeの設定
// @see https://nextjs.org/docs/api-routes/api-middlewares#custom-config
export const config = {
  api: {
    bodyParser: false,
  },
}

// Apollo Serverの生成
const isDevelopment = process.env.NODE_ENV === 'development'
const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: isDevelopment,
  plugins: [
    isDevelopment
      ? ApolloServerPluginLandingPageGraphQLPlayground()
      : ApolloServerPluginLandingPageDisabled(),
  ],
})

// 起動と登録
const startServer = apolloServer.start()
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  await startServer
  await apolloServer.createHandler({
    path: '/api/graphql',
  })(req, res)
}

型定義を生成してリゾルバーを安全に実装してみよう

実は今のままではresolversを実装する際にSDLで定義されている型は一切考慮されません。例えばSDLでString型になっているフィールドに対して、booleanを渡すリゾルバーを書いたとしても型検査では何も言われません。それではさすがに困るのでSDLからTypeScriptの型定義を生成するようにしてみましょう。

npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

graphql-codegenはSDL等を元にgraphqlに関するコードや型を生成する事ができるツールです。インストールが終わったら設定ファイルとして下記を作成します。

codegen.json
{
  "generates": {
    "./graphql/generated/resolvers-types.ts": {
      "schema": "./graphql/typeDefs.graphql",
      "plugins": ["typescript", "typescript-resolvers"]
    },
  }
}

またnpmスクリプトとして下記を追加しておきましょう

package.json
  "scripts": {
    "dev": "next dev",
-    "build": "next build",
+    "build": "npm run gen && next build",
    "start": "next start",
    "lint": "next lint",
    "deploy": "firebase deploy --only functions,hosting",
+    "gen": "npx graphql-codegen"
  },

それではコード生成を実行しましょう。

npm run gen

graphql/generated/resolvers-types.tsというファイルが作成されたらresolvers.tsでResolvers型をimportしてConfig['resolvers']型の代わりに使ってみましょう。

graphql/resolvers.ts
-import { Config } from 'apollo-server-micro'
+import { Resolvers } from './generated/resolvers-types'

-export const resolvers: Config['resolvers'] = {
+export const resolvers: Resolvers = {

banners以外のリゾルバーで型エラーが出ているのは、スキーマ側のStatus型が内部でもっているAuthor型について、TS側のリゾルバーチェーン適用前の型と不一致を起こしているためです。

スキーマ側の型とTS側の型とをマッピングする設定を追加して解消させましょう。

codegen.json
{
  "generates": {
    "./graphql/generated/resolvers-types.ts": {
      "schema": "./graphql/typeDefs.graphql",
+      "config": {
+        "mapperTypeSuffix": "Ts",
+        "mappers": {
+          "Status": "../resolvers#Status",
+          "Author": "../resolvers#Author"
+        }
+      },
      "plugins": ["typescript", "typescript-resolvers"]
    },
  }
}

mappersには生成されるファイル(この場合はresolvers-types.ts)から見ての相対パスでマッピング先ファイルを指定する必要があります。

graphql/resolvers.ts
-type Status = {
+export type Status = {

-type Author = { id: string; name: string }
+export type Author = { id: string; name: string }

またマッピング先になるTS側の型は生成ファイル(resolvers-types.ts)からimportされるため、exportしておく必要があります。

設定が終わったら型定義を再生成しましょう。

npm run gen

うまく設定されていればStatus.authorリゾルバーの型エラーだけが残るはずです。

type Status {
  id: ID!
  body: String!
  author: Author!
  createdAt: String!
}

この型エラーは、スキーマによって指定されているStatus.authorがAuthor!型(非nullのAuthor型)であるのに対して、リゾルバーがAuthor | undefined型を返しているために発生している型エラーです。どう修正するべきかはどういう仕様にしたいか次第ですが、今回はAuthor | null型へ統一する事で型を一致させましょう。

type Status {
  id: ID!
  body: String!
-  author: Author!
+  author: Author
  createdAt: String!
}
  Status: {
    author: (parent) => {
-      return getAuthor(parent.authorId)
+      return getAuthor(parent.authorId) ?? null
    },
  },

再生成をして型エラーがなくなっている事を確認しておきましょう。

npm run gen

ところで今回のStatus.authorリゾルバーの実装ミスについて、皆さんは型検査が有効化される前に気付けていたでしょうか?

筆者はもちろん気付いていませんでした。こういった人間の注意力に依存したミスを機械的に検査できる環境の重要性は本書の大きなテーマです。

GraphQLファイルにもESLintをかけてみよう

GraphQLのクエリーを書き始める前に、GraphQLファイルにもLintがかかるよう設定しておきましょう。

GraphQLのLintの中にはスキーマ情報が必要なものもあります。スキーマ情報の元としては前の章で記述したgraphql/typeDefs.graphqlそのものを利用したり、http://localhost:3000/api/graphql
のようなGraphQLエンドポイントURLを利用する事もできますが、ここでは敢えてエンドポイントURLからSDLファイルを生成してそれを利用します。

graphql/typeDefs.graphqlと同じ内容のファイルを生成する事になるので今回のケースでは回りくどいと感じるかもしれませんが、GraphQLサーバーが直接管理下にないケースや、GraphQLサーバーにおいてSDLファイルではなくコードファーストでスキーマが定義(例えばTypeScriptであればNexusなどで実現できます)されているケースなどでも利用できる手法です。

先ずはSDLファイル生成のためのプラグインとESLintプラグインをそれぞれインストールします。

npm install --save-dev @graphql-codegen/schema-ast @graphql-eslint/eslint-plugin 

インストールが終わったらSDLファイル生成のための設定をcodege.jsonへ追加しましょう。

codegen.json
{
  "generates": {
    "./graphql/generated/resolvers-types.ts": {
      "schema": "./graphql/schema/typeDefs.graphql",
      "config": {
        "mapperTypeSuffix": "Ts",
        "mappers": {
          "Status": "../resolvers#Status",
          "Author": "../resolvers#Author"
        }
      },
      "plugins": ["typescript", "typescript-resolvers"]
    },
+    "./graphql/generated/schema.graphql": {
+      "schema": "http://localhost:3000/api/graphql",
+      "plugins": ["schema-ast"]
+    }
  }
}

設定が終わったら一度ファイル生成を実行します。この時にはGraphQLサーバー(を乗せているNext.js)を起動しておくようにしてください。生成にあたってIntrospection機能でGraphQLサーバーからスキーマ情報を取得する必要があるためです。

npm run gen

graphql/generated/schema.graphqlgraphql/typeDefs.graphqlと同じ内容のファイルが生成されたでしょうか。次はこのschema.graphqlを元にしたlintの設定です。eslintrc.jsonを変更しましょう。

.eslintrc.json
{
  "extends": [
    "next/core-web-vitals",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ],
+  "overrides": [
+    {
+      "files": ["./graphql/operations/**/*.graphql"],
+      "parserOptions": {
+        "operations": "./graphql/operations/**/*.graphql",
+        "schema": "./graphql/generated/schema.graphql"
+      },
+      "extends": "plugin:@graphql-eslint/operations-recommended"
+    },
+    {
+      "files": ["./graphql/schema/**/*.graphql"],
+      "parserOptions": {
+        "schema": "./graphql/schema/**/*.graphql"
+      },
+      "extends": "plugin:@graphql-eslint/schema-recommended"
+    }
+  ]
}

overridesの下へ2ブロックの設定を追加していますが、前者はGraphQLクライアントで扱うqueryやmutationといったファイルのlint設定、後者はGraphQLサーバーで扱うSDLのlint設定です。また設定に合わせてgraphql/typeDefs.graphqlgraphql/schema/typeDefs.graphqlへ移動しpages/api/graphql.tsのimportパスやcodegen.json内のファイルパスも修正しておきましょう。

最後にVSCode上のESLintで*.graphqlファイルも対象になるよう設定を追加します。

settings.json
{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
  "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "typescript.tsdk": "./node_modules/typescript/lib",
  "typescript.preferences.importModuleSpecifier": "non-relative",
+  "eslint.validate": [
+    "javascript",
+    "javascriptreact",
+    "typescript",
+    "typescriptreact",
+    "graphql"
+  ]
}

一通りの設定が終わったら動作確認のため下記のクエリーファイルを作ってVSCode上での表示を確認してみましょう。

graphql/query/StatusPageProps.graphql
query StatusPageProps($statusId: ID!, $bannerGroupId: ID!) {
  status(id: $statusId) {
    id
    body
    author {
      id
      name
    }
    createdAt
  }
  banners(groupId: $bannerGroupId) {
    id
    hrefs
  }
}

bannersのレスポンス型に存在しないhrefsというフィールド名の指定にちゃんと警告が表示されているでしょうか。警告を確認できたらhrefsを正しいフィールド名のhrefへ修正しておきましょう。次は既存のgraphql/schema/typeDefs.graphqlを表示してみましょう。

概要コメントを付けるよう注意されているので追加しておきます。

graphql/schema/typeDefs.graphql
+"""
+クエリーの一覧
+"""
type Query {
  status(id: ID!): Status
  statuses: [Status]!
  banners(groupId: ID!): [Banner]!
}

+"""
+つぶやき
+"""
type Status {
  id: ID!
  body: String!
  author: Author
  createdAt: String!
}

+"""
+作者
+"""
type Author {
  id: ID!
  name: String!
}

+"""
+バナー
+"""
type Banner {
  id: ID!
  groupId: ID!
  href: String
}

GraphQLクライアントをNext.jsのサーバーサイドで使ってみよう

クエリーファイルが出来たので今度はこれを実際にNext.jsから使ってみましょう。

コラム:Queryをどう配置するか?Fragment Colocationという考え方

本書では最上位のコンポーネントであるページコンポーネントと対応させたクエリーだけを配置しました。これはトップダウンの考え方に基づいていると言えますが、逆にボトムアップなアプローチもあります。

fragment AuthorNamePart on Author {
  id
  name
}

GraphQL Queryにはデータ構造の一部分を切り出して定義するFragmentと呼ばれる機能があります。Fragment Colocationというアプローチでは個々の末端コンポーネントが必要とするデータ構造(props)をこのFragmentで定義してそのコンポーネントとセットで配置し、実際に発行するQueryはそのページで利用するコンポーネントに応じたFragmentを集めて書きます。

Fragment Colocationではコンポーネントとクエリーとの物理的な位置が近いため個々のコンポーネントの変更に伴うクエリーの変更漏れが起こりにくく、余分なデータの取得(オーバーフェッチ)も起こりにくい等のメリットがあります。なおApollo Clientと並んで人気があるGraphQLクライアントのRelayではFragment Colocationが基本方針となっています。

GraphQLクライアントライブラリと、クエリーファイルからTypeScriptコードと型を生成するためのプラグインをインストールします。

npm install @apollo/client
npm install --save-dev @graphql-codegen/typescript-react-apollo @graphql-codegen/typescript-operations 

必要なパッケージをインストールしたらcodegen.jsonに設定を追加しましょう。

codegen.json
{
  "generates": {
    "./graphql/generated/resolvers-types.ts": {
      "schema": "./graphql/schema/typeDefs.graphql",
      "config": {
        "mapperTypeSuffix": "Ts",
        "mappers": {
          "Status": "../resolvers#Status",
          "Author": "../resolvers#Author"
        }
      },
      "plugins": ["typescript", "typescript-resolvers"]
    },
    "./graphql/generated/schema.graphql": {
      "schema": "http://localhost:3000/api/graphql",
      "plugins": ["schema-ast"]
    },
+    "./graphql/generated/operations.ts": {
+      "schema": "http://localhost:3000/api/graphql",
+      "documents": "./graphql/operations/**/*.graphql",
+      "plugins": [
+        "typescript",
+        "typescript-operations",
+        "typescript-react-apollo"
+      ]
+    }
  }
}

再びコード生成を実行します。

npm run gen

graphql/generated/operations.tsというファイルが生成されたでしょうか。これにはクエリーファイルから生成された型定義やReactからクエリーを便利に扱うための独自フックの実装が含まれています。続けてApollo Clientを環境変数と使うため下記のファイルを作成します。

graphql/apollo-client.ts
import { ApolloClient, InMemoryCache } from '@apollo/client'

const endpointUrl = process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT_URL

export const apolloClient = new ApolloClient({
  uri: endpointUrl,
  cache: new InMemoryCache(),
})

.env
XDG_CONFIG_HOME=.config
API_ROOT=http://localhost:3000/
+NEXT_PUBLIC_GRAPHQL_ENDPOINT_URL=http://localhost:3000/api/graphql

それではStatusPageのサーバーサイド処理からApollo Clientを使ってみましょう。SWRヘッダの設定についてはページコンポーネントの見通しが悪くなるので、これを機に分割してします。

src/utils.ts
import { GetServerSidePropsContext } from 'next'
import { ParsedUrlQuery } from 'querystring'

export const setSwrHeader = (
  context: GetServerSidePropsContext<ParsedUrlQuery>,
  sMaxAgeSec: number,
  swrSec: number
): void => {
  context.res.setHeader(
    'Cache-Control',
    `public, s-maxage=${sMaxAgeSec}, stale-while-revalidate=${swrSec}`
  )
}

export const sec = {
  fromMinutes: (m: number): number => 60 * m,
  fromHours: (h: number): number => 3600 * h,
  fromDays: (d: number): number => 86400 * d,
}
pages/statuses/[id].tsx
import type { GetServerSideProps, NextPage } from 'next'
import Head from 'next/head'
import { BirdHouseLayout } from '@/atoms/layouts/BirdHouseLayout'
import { StatusCard } from '@/moleclues/StatusCard'
import {
  StatusPagePropsDocument,
  StatusPagePropsQuery,
} from 'graphql/generated/operations'
import { sec, setSwrHeader } from 'src/utils'
import { apolloClient } from 'graphql/apollo-client'

type StatusPageProps = Omit<StatusPagePropsQuery, 'status'> & {
  status: NonNullable<StatusPagePropsQuery['status']>
}

export const getServerSideProps: GetServerSideProps<StatusPageProps> = async (
  context
) => {
  if (typeof context.query.id !== 'string') {
    return { notFound: true }
  }

  setSwrHeader(context, sec.fromMinutes(10), sec.fromDays(30))
  try {
    const props = await fetchProps(context.query.id)
    return { props }
  } catch {
    return { notFound: true }
  }
}

// GraphQLでのデータ取得
const fetchProps = async (statusId: string) => {
  const result = await apolloClient.query<StatusPagePropsQuery>({
    query: StatusPagePropsDocument,
    variables: { statusId: statusId, bannerGroupId: '1' },
  })
  const status = result.data.status
  if (!status) {
    throw new Error('status not found')
  }

  return { ...result.data, status }
}

const StatusPage: NextPage<StatusPageProps> = ({ status }) => (
  <BirdHouseLayout>
    <>
      <Head>
        <title>{status.body}</title>
        <meta property="og:title" content={status.body} key="ogtitle" />
      </Head>
      <StatusCard
        {...status}
        author={status.author?.name ?? 'John Doe'}
        linkEnabled={false}
      />
    </>
  </BirdHouseLayout>
)

export default StatusPage

ページコンポーネントのコードから順番に見てみましょう。

type StatusPageProps = Omit<StatusPagePropsQuery, 'status'> & {
  status: NonNullable<StatusPagePropsQuery['status']>
}

StatusPagePropsQueryという型はStatusPageProps.graphqlを元にoperations.tsへ自動生成された型です。両者を見比べてみましょう。

graphql/operations/query/StatusPageProps.graphql
query StatusPageProps($statusId: ID!, $bannerGroupId: ID!) {
  status(id: $statusId) {
    id
    body
    author {
      id
      name
    }
    createdAt
  }
  banners(groupId: $bannerGroupId) {
    id
    href
  }
}

graphql/generated/operations.ts
export type StatusPagePropsQuery = { 
  __typename?: 'Query', 
  status?: { 
    __typename?: 'Status', 
    id: string, 
    body: string, 
    createdAt: string, 
    author?: { 
      __typename?: 'Author', 
      id: string, 
      name: string 
    } | null 
  } | null, 
  banners: Array<{ 
    __typename?: 'Banner', 
    id: string, 
    href?: string | null 
  } | null> 
};

__typename同じ構造の型があった時に両者が混同されてしまうのを区別するため自動生成されているプロパティです。なおApollo Clientのデフォルト設定では__typenameidと合わせる事でクライアントサイドキャッシュの管理にも使われます。

StatusPagePropsの型定義に戻りましょう。

type StatusPageProps = Omit<StatusPagePropsQuery, 'status'> & {
  status: NonNullable<StatusPagePropsQuery['status']>
}

ここで登場しているOmit<Type, Keys>NonNullable<Type>ユーティリティ型と呼ばれているものです。Omit<Type, Keys>はType型からKeysで指定した名前のプロパティを取り除いた新しい型を、NonNullable<Type>はType型からnull可能性を削除した新しい型を得る事ができます。

また&は交差型(Intersection Type)を使うための記法です。交差型を使うと2つの型を合成したような型を作り出す事ができます。

type Pet = { name: string }
type Dog = { breedName: string }
type PetDog = Pet & Dog
// type PetDog = { name: string; breedName: string }と同義

これらを組合せて何をしているかをまとめると「StatusPagePropsQuery型を素材にしてstatusプロパティが非nullなStatusPageProps型を新しく作っている」という事になります。こういった既に存在する型を素材に新しい型を作る操作は上手く使うと開発をする上での型の運用を大幅に効率化してくれます。標準で用意されているユーティリティー型以外にも例えばtype-festのような型ライブラリも存在するため記憶に留めておくと良いでしょう。

型の話はこれぐらいにして実装を見てみましょう。

// GraphQLでのデータ取得
const fetchProps = async (statusId: string) => {
  const result = await apolloClient.query<StatusPagePropsQuery>({
    query: StatusPagePropsDocument,
    variables: { statusId: statusId, bannerGroupId: '1' },
  })
  const status = result.data.status
  if (!status) {
    throw new Error('status not found')
  }

  return { ...result.data, status }
}

apolloClientへ渡しているStatusPagePropsDocumentStatusPageProps.graphqlのTypeScript表現でありクエリーそのものです。なお最後のreturnで新しくオブジェクトを生成してから返しているのはstatusプロパティがnullチェック済であり非nullである事を明確にするためです。

変更が終わったら下記を表示して、これまで通りのページが表示されている事を確認してみましょう。

http://localhost:3000/statuses/1

GraphQLクライアントをNext.jsのクライアントサイドで使ってみよう

前章ではサーバーサイド(Node.js)からGraphQLサーバーへアクセスしましたが、次はクライアントサイド(ブラウザ)からHooksを通じてGraphQLサーバーへアクセスしてみましょう。

pages/_app.tsx
import { NextComponentType } from 'next'
import {
  AppContextType,
  AppInitialProps,
  AppPropsType,
} from 'next/dist/shared/lib/utils'
import Head from 'next/head'
import { ThemeProvider } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import { CacheProvider, EmotionCache } from '@emotion/react'
import createEmotionCache from 'src/createEmotionCache'
import { Theme } from 'src/theme'
+import { ApolloProvider } from '@apollo/client'
+import { apolloClient } from 'graphql/apollo-client'

type MyAppProps = AppPropsType & { emotionCache?: EmotionCache }
export type MyAppType = NextComponentType<
  AppContextType,
  AppInitialProps,
  MyAppProps
>

// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache()

const MyApp = (props: MyAppProps) => {
  const { Component, emotionCache = clientSideEmotionCache, pageProps } = props
  return (
+    <ApolloProvider client={apolloClient}>
      <CacheProvider value={emotionCache}>
        <Head>
          <meta name="viewport" content="initial-scale=1, width=device-width" />
        </Head>
        <ThemeProvider theme={Theme}>
          {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
          <CssBaseline />
          <Component {...pageProps} />
        </ThemeProvider>
      </CacheProvider>
+    </ApolloProvider>
  )
}

export default MyApp

以上で準備は終了です。

それでは以下のクエリーを追加してコード生成をしましょう。

graphql/operations/query/HomePageProps.graphql
query HomePageProps($bannerGroupId: ID!) {
  statuses {
    id
    body
    author {
      id
      name
    }
    createdAt
  }
  banners(groupId: $bannerGroupId) {
    id
    href
  }
}

npm run gen

続けてHomePageを下記のように変更しましょう。

pages/index.tsx
import { NextPage } from 'next'
import Head from 'next/head'
import { Backdrop, Box, CircularProgress, Typography } from '@mui/material'
import { BirdHouseLayout } from '@/atoms/layouts/BirdHouseLayout'
import { StatusCard } from '@/moleclues/StatusCard'
import { useHomePagePropsQuery } from 'graphql/generated/operations'

const HomePage: NextPage = () => {
  const { loading, error, data } = useHomePagePropsQuery({
    variables: { bannerGroupId: '1' },
  })

  if (loading) {
    return (
      <Backdrop open={true}>
        <CircularProgress />
      </Backdrop>
    )
  }

  if (error) {
    return (
      <Backdrop open={true}>
        <Typography>エラーが発生しました</Typography>
      </Backdrop>
    )
  }

  return (
    <BirdHouseLayout currentRouteName="home">
      <>
        <Head>
          <title>最新ステータス</title>
          <meta property="og:title" content="最新ステータス" key="ogtitle" />
        </Head>
        {data?.statuses.map(
          (status) =>
            status && (
              <Box key={status.id} pb={2}>
                <StatusCard
                  {...status}
                  author={status.author?.name ?? 'John Doe'}
                />
              </Box>
            )
        )}
      </>
    </BirdHouseLayout>
  )
}

export default HomePage

useHomePagePropsQueryHomePageProps.graphqlから生成されたHooksです。useHomePagePropsQueryは通信の進捗に応じて左辺の{ loading, error, data }を変化させるので、その変化に応じてどういったパターンのコンポーネントを返すのかを記述します。

変更を保存したら表示を確認してみましょう。

http://localhost:3000/

F12キーを押してデベロッパーツールを開き「Network」の「XHR」で、ブラウザとGraphQLサーバーとの間の通信も確認してみましょう。

PreviewタブでGraphQLサーバーからのレスポンスを確認する事ができます。