この記事では、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
というデータ構造を定義している。User
はid
とname
を持つ。末尾の!
は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
を利用しているため、このように型の恩恵を受けることができる。
同様に、name
をtrue
にするなど、型が間違っていてもエラーになる。
ただ、以下のように余計なプロパティがついていても型エラーにはならないので注意する。
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-codegen
のwatch
オプションを使うことで、ファイル更新時に自動的に再生成されるようになる。
$ 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.json
のscripts
フィールドを以下のように書き換える。
"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
GetUsersNameDocument
をGetUsersDocument
に置換すると解決すると同時に、取得した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 つを使ってもいいのだが、ひとつのクエリでまとめてUser
とTeam
を取得することができる。
具体的には、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> ); }
一度のリクエストでUser
とTeam
の両方を取得できていることが分かる。
クエリ引数
クエリ引数を使うことで、特定の条件を満たしたデータのみを取得することもできる。
例として、スキーマ定義に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.tsx
にAddUser
を追加すれば完成。
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
を検索すると情報を取得できる。