30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Next.js で始める GraphQL

この記事では、GraphQL を利用したアプリを Next.js で構築していきながら、GraphQL の初歩について書いていく。

GraphQL のクライアントもサーバも、Apollo を用いる。
また、できるだけ型安全に開発したいので、graphql-codegenで型定義ファイルを生成する方法も扱う。

利用しているライブラリのバージョンは以下の通り。

  • @apollo/client@3.5.10
  • @graphql-codegen/cli@2.6.2
  • @graphql-codegen/typed-document-node@2.2.7
  • @graphql-codegen/typescript-operations@2.3.4
  • @graphql-codegen/typescript-resolvers@2.5.4
  • @graphql-codegen/typescript@2.4.7
  • @types/node@17.0.21
  • @types/react@17.0.40
  • apollo-server-micro@3.6.4
  • concurrently@7.0.0
  • eslint-config-next@12.1.0
  • eslint@8.11.0
  • graphql@16.3.0
  • micro@9.3.4
  • next@12.1.0
  • react-dom@17.0.2
  • react@17.0.2
  • typescript@4.6.2

スキーマを定義する

まず、Next.js の環境を構築する。

$ yarn create next-app sample --ts

プロジェクトのルートディレクトリ(今回の例だとsample)にgraphqlディレクトリを作り、そのなかに以下の内容のschema.graphqlを作る。

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

Userというデータ構造を定義している。Useridnameを持つ。末尾の!nullを許容しないことを意味している。

次に、クライアントがこのUserを取得するためのクエリのスキーマを定義する。
schema.graphqlに以下を追記する。

type Query {
  users: [User!]!
}

usersというクエリを定義しており、このクエリを使うとクライアントはUserの配列を得ることができる。

GraphQL サーバを構築する

今回は Next.js の API Routes を GraphQL サーバとして使うことにする。
そのためpages/api以下にファイルを作成することになるが、その前にまず、先程定義したスキーマに基づいて型定義ファイルを生成する。

ファイル生成に必要なライブラリをインストール。

$ yarn add graphql
$ yarn add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

続いて、以下の内容のgraphql/codegen-server.yamlを作る。

schema: './graphql/schema.graphql'
generates:
  ./graphql/dist/generated-server.ts:
    config:
      useIndexSignature: true
    plugins:
      - typescript
      - typescript-resolvers

この状態で以下のコマンドを実行すると、graphql/dist/generated-server.tsが生成される。

$ yarn run graphql-codegen --config graphql/codegen-server.yaml

このファイルを使うことで、型の恩恵を受けながら GraphQL サーバを開発することができる。

今回はmicroを使って GraphQL サーバを構築するので、そのために必要なライブラリをインストールする。

$ yarn add micro apollo-server-micro

以下の内容のpages/api/graphql.tsを作成する。

import { NextApiRequest, NextApiResponse } from "next";
import { ApolloServer } from "apollo-server-micro";
import { readFileSync } from "fs";
import { join } from "path";

import { Resolvers } from "../../graphql/dist/generated-server";

const path = join(process.cwd(), "graphql", "schema.graphql");
const typeDefs = readFileSync(path).toString("utf-8");

// スキーマと実際のデータ構造の紐付けを resolvers で行う
const resolvers: Resolvers = {};

const apolloServer = new ApolloServer({ typeDefs, resolvers });

const startServer = apolloServer.start();

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  await startServer;
  await apolloServer.createHandler({
    path: "/api/graphql",
  })(req, res);
}

export const config = {
  api: {
    bodyParser: false,
  },
};

スキーマ定義の際にusersというクエリを定義したが、実際にどのようなロジックでどのようなデータを返すのかは、まだどこにも定義されていない。
それを定義するのがリゾルバである。
今回はpages/api/graphql.tsにデータをハードコーディングするが、データベースから取得してもいいし、他のサービスの API から取得してもいい。

早速書いてみる。

const resolvers: Resolvers = {
  Query: {
    users: () => [{ id: "1" }], // Type Error
  },
};

上記のように書くと型エラーが出るが、これはnameが含まれていないため。
自動生成されたResolversを利用しているため、このように型の恩恵を受けることができる。
同様に、nametrueにするなど、型が間違っていてもエラーになる。
ただ、以下のように余計なプロパティがついていても型エラーにはならないので注意する。

const resolvers: Resolvers = {
  Query: {
    users: () => [{ id: "1", name: "Alice", foo: "bar" }],
  },
};

今回は以下のようにした。

const users = [
  { id: "1", name: "Alice" },
  { id: "2", name: "Bob" },
  { id: "3", name: "Carol" },
];

const resolvers: Resolvers = {
  Query: {
    users: () => users,
  },
};

これでサーバの構築は完了したので、クライアントの構築に移る。

GraphQL クライアントを構築する

サーバと同様、まずは型定義ファイルを生成するための作業を行う。

以下の内容のgraphql/query.graphqlを作る。

query getUsersName {
  users {
    name
  }
}

GraphQL では、どのようなデータを取得するのかをクライアント側が決める。
上記の例では、usersクエリを使ってnameのみを取得している。idは不要と判断し、取得していない。

コード生成のためのライブラリをインストール。

$ yarn add -D @graphql-codegen/typed-document-node @graphql-codegen/typescript-operations

graphql/codegen-client.yamlを作る。

schema: './graphql/schema.graphql'
documents: './graphql/*.graphql'
generates:
  ./graphql/dist/generated-client.ts:
    plugins:
      - typescript
      - typescript-operations
      - typed-document-node

この状態で以下のコマンドを実行すると、graphql/dist/generated-client.tsが生成される。

$ yarn run graphql-codegen --config graphql/codegen-client.yaml

これで型定義ファイルが生成されたので、実際に GraphQL クライアントを構築していく。
サーバと同様に Apollo を使うので、インストールする。

$ yarn add @apollo/client

そして、pages/index.tsxを以下のように書き換える。

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
} from "@apollo/client";
import { GetUsersNameDocument } from "../graphql/dist/generated-client";

const client = new ApolloClient({
  uri: "http://localhost:3000/api/graphql",
  cache: new InMemoryCache(),
});

function Users() {
  const { loading, error, data } = useQuery(GetUsersNameDocument);

  if (loading) return <p>Loading...</p>;
  if (error || !data) return <p>Error</p>;

  return (
    <ul>
      {data.users.map((user, index: number) => {
        console.log(Object.getOwnPropertyNames(user)); // ['__typename', 'name']
        return <li key={index}>{user.name}</li>;
      })}
    </ul>
  );
}

export default function App() {
  return (
    <ApolloProvider client={client}>
      <Users />
    </ApolloProvider>
  );
}

$ yarn devを実行してhttp://localhost:3000/にアクセスすると、ユーザー名のリストが表示されているはず。
そして、Object.getOwnPropertyNames(user)の結果を見ると分かるように、idは取得されておらず、指定したデータのみを取得できていることが分かる。

watch オプションを使う

今の状態だと、スキーマ定義などが更新される度に、コマンドを実行して型定義ファイルを生成しないといけない。
graphql-codegenwatchオプションを使うことで、ファイル更新時に自動的に再生成されるようになる。

$ yarn run graphql-codegen --config graphql/codegen-server.yaml --watch
$ yarn run graphql-codegen --config graphql/codegen-client.yaml --watch

開発環境の起動時にこれらのコマンドを実行すると便利なので、npm scriptsを使って実現する。

まず、複数の npm script を実行させたいので、concurrentlyをインストールする。

$ yarn add -D concurrently

次に、package.jsonscriptsフィールドを以下のように書き換える。

  "scripts": {
    "dev": "concurrently \"yarn run generate-client --watch\" \"yarn run generate-server --watch\" \"next dev\"",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "generate-client": "graphql-codegen --config graphql/codegen-server.yaml",
    "generate-server": "graphql-codegen --config graphql/codegen-client.yaml"
  },

この状態で$ yarn devを実行すると、スキーマ定義やクエリを更新した際に、型定義ファイルも自動的に再生成される。

例えば、graphql/query.graphqlを以下のように書き換えると、graphql/dist/generated-clientも自動的に再生成される。

query getUsers {
  users {
    id
    name
  }
}

そのため、pages/index.tsxの以下の部分がエラーになる。

import { GetUsersNameDocument } from "../graphql/dist/generated-client"; // Error

GetUsersNameDocumentGetUsersDocumentに置換すると解決すると同時に、取得したuserのなかにidが含まれるようになる。

console.log(Object.getOwnPropertyNames(user)); // ['__typename', 'id', 'name']

これでアプリの構築は終わったが、このアプリを題材にして、GraphQL を使い方をもう少し見ていく。

一度のリクエストで複数のクエリを利用する

GraphQL のメリットとしてよく語られることのひとつに、一度のリクエストで必要なデータを取得できる、というものがある。
例として、UserだけでなくTeamというデータ構造も使うことになったケースについて考えてみる。

まず、スキーマにTeamを追加する。

# graphql/schema.graphql
type User {
  id: ID!
  name: String!
  teamName: String!
}

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

type Query {
  users: [User!]!
  teams: [Team!]!
}

pages/api/graphql.tsに型エラーが出ているはずなので、新しいスキーマ定義に合わせて修正する。

type Team = "Red" | "White";

const teams: { id: string; name: Team }[] = [
  { id: "1", name: "Red" },
  { id: "2", name: "White" },
];

type User = { id: string; name: string; teamName: Team };

const users: User[] = [
  { id: "1", name: "Alice", teamName: "Red" },
  { id: "2", name: "Bob", teamName: "Red" },
  { id: "3", name: "Carol", teamName: "White" },
];

const resolvers: Resolvers = {
  Query: {
    users: () => users,
    teams: () => teams,
  },
};

次にgraphql/query.graphqlにクエリを書いていく。
この際、getUsersと同じ要領でgetTeamsを書いてその 2 つを使ってもいいのだが、ひとつのクエリでまとめてUserTeamを取得することができる。

具体的には、graphql/query.graphqlに以下の内容を追記すればよい。

query getUsersAndTeams {
  users {
    id
    name
    teamName
  }
  teams {
    id
    name
  }
}

そうするとクライアントでGetUsersAndTeamsDocumentを使えるようになっているので、pages/index.tsxを以下のように書き換える。

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
} from "@apollo/client";
import { GetUsersAndTeamsDocument } from "../graphql/dist/generated-client";

const client = new ApolloClient({
  uri: "http://localhost:3000/api/graphql",
  cache: new InMemoryCache(),
});

function UsersAndTeams() {
  const { loading, error, data } = useQuery(GetUsersAndTeamsDocument);

  if (loading) return <p>Loading...</p>;
  if (error || !data) return <p>Error</p>;

  const { users, teams } = data;

  return (
    <>
      <h1>Team List</h1>
      <ul>
        {teams.map(({ id, name }) => {
          return <li key={id}>{name}</li>;
        })}
      </ul>
      <h1>User List</h1>
      <ul>
        {users.map(({ id, name, teamName }) => {
          return (
            <li key={id}>
              <b>{name}</b> belong {teamName} team
            </li>
          );
        })}
      </ul>
    </>
  );
}

export default function App() {
  return (
    <ApolloProvider client={client}>
      <UsersAndTeams />
    </ApolloProvider>
  );
}

一度のリクエストでUserTeamの両方を取得できていることが分かる。

クエリ引数

クエリ引数を使うことで、特定の条件を満たしたデータのみを取得することもできる。

例として、スキーマ定義にuserというクエリを追加する。
引数としてnameを受け取り、それに基づいて特定のUserを返す。該当するUserが存在しないケースがあり得るので、!はつけていない。

# graphql/schema.graphql
type Query {
  users: [User!]!
  teams: [Team!]!
  user(name: String!): User
}

pages/api/graphql.tsのリゾルバに、以下のuserメソッドを追加する。
実装をみれば分かるが、渡されたnameと一致するユーザーが存在した場合はそのユーザーを、存在しなかった場合はnullを返す。

    user: (_, { name: specifiedName }) => {
      const user = users.find(({ name }) => name === specifiedName);
      return user || null;
    },

クライアントが使用するクエリを定義する。

# graphql/query.graphql
query getUser($name: String!) {
  user(name: $name) {
    id
    name
    teamName
  }
}

GetUserDocumentが使えるようになるので、pages/index.tsxを以下のように書き換える。

import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
} from "@apollo/client";
import { useState, memo } from "react";
import { GetUserDocument } from "../graphql/dist/generated-client";

const client = new ApolloClient({
  uri: "http://localhost:3000/api/graphql",
  cache: new InMemoryCache(),
});

const User = memo(function User({ specifiedName }: { specifiedName: string }) {
  const { loading, error, data } = useQuery(GetUserDocument, {
    variables: { name: specifiedName },
  });

  if (loading) return <p>Loading...</p>;
  if (error || !data) return <p>Error</p>;

  const { user } = data;

  if (!user) {
    return <div>user not found.</div>;
  }

  const { id, name, teamName } = user;

  return (
    <div>
      <span>id: {id}</span>
      <br />
      <span>name: {name}</span>
      <br />
      <span>team {teamName}</span>
    </div>
  );
});

function SearchUser() {
  const [text, setText] = useState("");
  const [specifiedName, setSpecifiedName] = useState("");

  return (
    <>
      <h1>Search User</h1>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          setSpecifiedName(text);
        }}
      >
        <input
          type="text"
          value={text}
          onChange={(e) => {
            setText(e.currentTarget.value);
          }}
        />
      </form>
      <User specifiedName={specifiedName} />
    </>
  );
}

export default function App() {
  return (
    <ApolloProvider client={client}>
      <SearchUser />
    </ApolloProvider>
  );
}

テキストボックスにユーザー名を入力して送信(エンター)すると、その結果が表示されるはず。

Mutation

ここまで見てきた内容は全て、GraphQL サーバからデータを取得するものだった。
最後に、データを操作する方法を紹介する。

データの取得にはQueryを使ってきたが、データの操作にはMutationを使う。

スキーマ定義、リゾルバ定義、クライアントが使うクエリを書く、クライアントの実装、という流れはこれまでと変わらない。

スキーマ定義

# graphql/schema.graphql に以下を追記
type Mutation {
  addUser(name: String!): User
}

リゾルバ定義

// pages/api/graphql.ts のリゾルバを更新
const addUser = (newUserName: string): User | null => {
  const user = users.find(({ name }) => name === newUserName);
  if (user) return null; // 同名のユーザーが存在した場合は null を返す
  const id = users.length + 1;
  const newUser: User = {
    id: String(id),
    name: newUserName,
    teamName: "White", // 実装が面倒なのでチームは必ず White にするようにした
  };
  users.push(newUser);
  return newUser;
};

const resolvers: Resolvers = {
  Query: {
    // 省略
  },
  Mutation: {
    addUser: (_, { name }) => addUser(name),
  },
};

クエリを書く

# graphql/query.graphql に以下を追記
mutation addUser($name: String!) {
  addUser(name: $name) {
    id
  }
}

クライアントの実装

pages/index.tsxAddUserを追加すれば完成。

function AddUser() {
  const [text, setText] = useState("");
  const [addUser, { loading, error, data }] = useMutation(AddUserDocument);

  const Status = () => {
    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error</p>;
    if (data && !data.addUser) return <p>同名のユーザーが既に存在します</p>;
    if (!data) return null;
    return <p>登録が完了しました</p>;
  };

  return (
    <>
      <h1>Add User</h1>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          addUser({
            variables: {
              name: text,
            },
          });
        }}
      >
        <input
          type="text"
          value={text}
          onChange={(e) => {
            setText(e.currentTarget.value);
          }}
        />
      </form>

      <Status />
    </>
  );
}

export default function App() {
  return (
    <ApolloProvider client={client}>
      <SearchUser />
      <AddUser /> // これを追加
    </ApolloProvider>
  );
}

試しにAliceを追加しようとすると失敗するし(既にAliceが存在するため)、Daveを追加してからDaveを検索すると情報を取得できる。