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

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

Prisma に入門して API サーバを作ってみる

Prisma は、Node.js の ORM。
この記事では、導入方法、基本的な使い方について説明したのち、Prisma を使って簡単な API サーバを作ってみる。

Node.js のバージョンは16.13.2、MySQLのバージョンは8.0.28という環境で、動作確認している。

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

  • @prisma/client@3.11.0
  • @types/node@16.11.26
  • prisma@3.11.0
  • ts-node-dev@1.1.8
  • typescript@4.6.2

MySQL のインストール

ORM である Prisma を試すためには、まずデータベースをセットアップしないといけない。今回は MySQL を使うことにする。
この記事では Homebrew を使ってインストールしているが、他の方法でももちろん問題ない。

% brew install mysql

バージョンを確認できればインストール成功。

% mysql --version
mysql  Ver 8.0.28 for macos11.6 on x86_64 (Homebrew)

brew services start mysqlで起動、brew services stop mysqlで終了。
以降、MySQL が起動しているという前提で話を進めていく。

以下のコマンドを打つといくつか質問されるので、root ユーザーのパスワード設定以外は全て No にする。Enter を押していけば No になるはず。

% mysql_secure_installation

以下のコマンドを打つと root ユーザーのパスワードを求められるので、先程設定したパスワードを入力すると、ログインできる。

% mysql --user=root --password

データベースの準備

MySQL にログインしたら、Prisma で操作するデータベースを用意する。
今回はprisma_dbという名前にしたが、特に制限はない。

mysql> CREATE DATABASE prisma_db;

SHOW DATABASES;で確認してprisma_dbが存在すれば成功。

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| prisma_db          |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

USE prisma_db;とすると、以降はprisma_dbに対して操作できるようになる。

SHOW TABLES;を実行すると、prisma_dbにはまだテーブルがないことを確認できる。

mysql> SHOW TABLES;
Empty set (0.00 sec)

最後に、このデータベースのポート番号を確認する。Prisma の設定時に必要になる。

mysql> show variables like 'port';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| port          | 3306  |
+---------------+-------+
1 row in set (0.00 sec)

Prisma プロジェクトを作成する

まずは必要なライブラリをインストールする。

% yarn init -y
% yarn add -D typescript ts-node-dev prisma

TypeScript で開発していくので、以下のtsconfig.jsonを作る。

{
  "compilerOptions": {
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext", "DOM"],
    "esModuleInterop": true
  }
}

以下のコマンドを実行すると、いくつかのファイルが生成される。

% yarn run prisma init

まず.env
このファイルでDATABASE_URLという環境変数が定義されているので、これを自身の環境に合わせて書き換える。
この記事の内容に沿って環境を構築した場合、以下のようになるはず。YOURPASSWORDの部分だけ、先程設定した root ユーザーのパスワードに書き換えればよい。

DATABASE_URL="mysql://root:YOURPASSWORD@localhost:3306/prisma_db"

そして、prisma/schema.prismaという名前のスキーマファイルも生成される。
以下の内容になっているはずなので、postgresqlの部分をmysqlに書き換える。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

モデルを定義する

環境構築が終わったのでここから実際に開発を進めていくが、まずはモデルを定義する。

先程編集したスキーマファイルに、モデルの定義を書き足していく。
スキーマファイルはPrisma Schema Language(PSL)という構文で書いていく。
また、.prismaファイルは、% yarn run prisma formatを実行することでフォーマットできる。

早速、Userというモデルを定義してみる。

model User {
  id      Int     @id @default(autoincrement())
  name    String
}

そして、以下のコマンドを実行する。

% yarn run prisma migrate dev --name init

そうすると様々な処理が行われるが、まず、@prisma/clientがまだインストールされていなかった場合はインストールされる。package.jsonに追記されていることを確認できる。
それから、prisma/migrationsディレクトリに、マイグレーションファイルが作成される。
そしてそのマイグレーションファイルをデータベースに対して実行する。

prisma_dbの中身を見てみると、Userテーブルが作られているのが分かる。

mysql> SHOW TABLES;
+---------------------+
| Tables_in_prisma_db |
+---------------------+
| _prisma_migrations  |
| User                |
+---------------------+

このように、Prisma のモデルはデータベースのテーブルに対応している。

DESCで調べてみると、モデルの定義通りに作成されている。

mysql> DESC User;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int          | NO   | PRI | NULL    | auto_increment |
| name  | varchar(191) | NO   |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+
2 rows in set (0.00 sec)

この時点ではレコードはまだ存在しない。

mysql> SELECT * FROM User;
Empty set (0.00 sec)

Prisma クライアントを使った CRUD 操作

Prisma クライアントを使ってコードを書いていくことで、データベースに対する操作を行うことができる。
ここでは、User テーブルに対して簡単な CRUD 操作を行っていく。

最初に、動作確認用の雛形となるコードを用意する。
src/index.tsという名前で以下のファイルを作成する。

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  // ここにクエリを書いていく
}

main()
  .catch((e) => {
    throw e;
  })
  .finally(async () => {
    // データベースとのコネクションを切る
    await prisma.$disconnect();
  });

あとはmain関数のなかに具体的な処理を書き、% yarn run ts-node-dev src/index.tsで実行すればいい。

レコードの作成

レコードを作成するにはcreateメソッドを使う。createの返り値は、作成されたUserのオブジェクト。

async function main() {
  const user = await prisma.user.create({
    data: {
      name: "Alice",
    },
  });
  console.log(user); // { id: 1, name: 'Alice' }
}

% yarn run ts-node-dev src/index.tsを実行したあとにデータベースで検索を行うと、レコードが作られていることがわかる。

mysql> SELECT * FROM User;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

dataに渡すフィールドがモデルの定義と一致していない場合、型エラーとなる。

// 以下は全て型エラー
async function main() {
  await prisma.user.create({
    data: {
      name: 1, // name の型が正しくない
    },
  });
  await prisma.user.create({
    data: {}, // name が渡されていない
  });
  await prisma.user.create({
    data: {
      name: 'Alice',
      bar: 'buz', // 不要なフィールド bar を渡している
    },
  });
}

実は、% yarn run prisma migrate dev --name initを実行した際にnode_modules内に型定義ファイルが自動生成されており、それを使って型チェックが行われている。

// node_modules/.prisma/client/index.d.ts から抜粋

/**
 * Model User
 * 
 */
export type User = {
  id: number
  name: string
}

idは auto_increment なので明示的に渡さなくてもよいのだが、もちろん渡してもよい。

async function main() {
  const user = await prisma.user.create({
    data: {
      id: 2,
      name: "Bob",
    },
  });
  console.log(user); // { id: 2, name: 'Bob' }
}

但し、既に存在しているidを渡してしまうと、プライマリキーとしての制約が守られていないので、実行時エラーになる。

async function main() {
  try {
    await prisma.user.create({
      data: {
        id: 2,
        name: "Carol",
      },
    });
  } catch (e) {
    // PrismaClientKnownRequestError
    // Unique constraint failed on the constraint: `PRIMARY`
    console.log(e);
  }
}

一度に複数のレコードを作成するときは、createManyを使う。

async function main() {
  const users = await prisma.user.createMany({
    data: [
      {
        name: "Carol",
      },
      {
        name: "Dave",
      },
    ],
  });
  console.log(users); // { count: 2 }
}
mysql> SELECT * FROM User;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
|  2 | Bob   |
|  3 | Carol |
|  4 | Dave  |
+----+-------+
4 rows in set (0.00 sec)

レコードの取得

レコードの取得を行うためのメソッドとしてここでは、findManyfindUniquefindFirstを紹介する。

まずfindMany。これは、複数のレコードを取得するメソッド。
条件を何も指定しなかった場合、全てのフィールドを持った全レコードが返ってくる。

async function main() {
  const allUsers = await prisma.user.findMany();
  console.log(allUsers);
  // [
  //   { id: 1, name: 'Alice' },
  //   { id: 2, name: 'Bob' },
  //   { id: 3, name: 'Carol' },
  //   { id: 4, name: 'Dave' }
  // ]
}

フィールドを指定するには、selectを使う。

async function main() {
  const users = await prisma.user.findMany({
    select: {
      name: true,
    },
  });
  console.log(users);
  // [
  //   { name: 'Alice' },
  //   { name: 'Bob' },
  //   { name: 'Carol' },
  //   { name: 'Dave' }
  // ]
}

レコードの絞り込みにはwhereを使う。
以下はcontainsを使って、「nameフィールドにoが含まれているレコード」を取得している。

async function main() {
  const users = await prisma.user.findMany({
    where: {
      name: {
        contains: "o",
      },
    },
  });
  console.log(users); // [ { id: 2, name: 'Bob' }, { id: 3, name: 'Carol' } ]
}

該当するレコードが存在しないときは空の配列を返す。

async function main() {
  const users = await prisma.user.findMany({
    where: {
      name: {
        contains: "z",
      },
    },
  });
  console.log(users); // []
}

contains以外にも様々な検索条件がある。詳しくは公式ドキュメントを参照。
https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#filter-conditions-and-operators

ORANDを使うこともできる。

// 「name が Al から始まる」 or 「id が 3 以上」
async function main() {
  const users = await prisma.user.findMany({
    where: {
      OR: [
        {
          name: {
            startsWith: "Al",
          },
        },
        {
          id: {
            gte: 3,
          },
        },
      ],
    },
  });
  console.log(users);
  // [
  //   { id: 1, name: 'Alice' },
  //   { id: 3, name: 'Carol' },
  //   { id: 4, name: 'Dave' }
  // ]
}
// 「name に o を含む」 and 「id が 3 以上」
async function main() {
  const users = await prisma.user.findMany({
    where: {
      AND: [
        {
          name: {
            contains: "o",
          },
        },
        {
          id: {
            gte: 3,
          },
        },
      ],
    },
  });
  console.log(users); // [ { id: 3, name: 'Carol' } ]
}

orderByを使って並べ替えを行うこともできる。

async function main() {
  const allUsers = await prisma.user.findMany({
    orderBy: {
      id: "desc",
    },
  });
  console.log(allUsers);
  // [
  //   { id: 4, name: 'Dave' },
  //   { id: 3, name: 'Carol' },
  //   { id: 2, name: 'Bob' },
  //   { id: 1, name: 'Alice' }
  // ]
}

findUniqueを使うと、ユニークな識別子を使って、該当するレコードを取得できる。
該当するレコードが存在しない場合はnullが返ってくる。

async function main() {
  let user = await prisma.user.findUnique({
    where: {
      id: 1,
    },
  });
  console.log(user); // { id: 1, name: 'Alice' }

  user = await prisma.user.findUnique({
    where: {
      id: 99,
    },
  });
  console.log(user); // null
}

ユニークではないフィールドを使おうとすると型エラーになる。

async function main() {
  const user = await prisma.user.findUnique({
    where: {
      name: "Alice", // 型エラー
    },
  });
}

findFirstは、最初に見つかったレコードを返す。

async function main() {
  const user = await prisma.user.findFirst({
    where: {
      name: {
        contains: "o",
      },
    },
  });
  console.log(user); // { id: 2, name: 'Bob' }
}
// orderBy で降順にしているので、{ id: 3, name: 'Carol' } が返ってくる
async function main() {
  const user = await prisma.user.findFirst({
    where: {
      name: {
        contains: "o",
      },
    },
    orderBy: {
      id: "desc",
    },
  });
  console.log(user); // { id: 3, name: 'Carol' }
}

レコードのアップデート

レコードのアップデートには、updateメソッドを使う。

async function main() {
  const user = await prisma.user.update({
    where: {
      id: 4,
    },
    data: {
      name: "David",
    },
  });
  console.log(user); // { id: 4, name: 'David' }
}

存在しないレコードをアップデートしようとすると、実行時エラーになってしまう。

// id が 99 のレコードの name をアップデートしようとしているが、該当するレコードは存在しないため、実行時エラーになる
async function main() {
  const user = await prisma.user.update({ // 実行時エラー
    where: {
      id: 99,
    },
    data: {
      name: "David",
    },
  });
  console.log(user);
}

レコードの削除

レコードの削除にはdeleteメソッドを使う。

async function main() {
  const user = await prisma.user.delete({
    where: {
      id: 4,
    },
  });
  console.log(user); // { id: 4, name: 'David' }
}

存在しないレコードを削除しようとすると、実行時エラーになってしまう。

// id が 99 のレコードを削除しようとしているが、該当するレコードは存在しないため、実行時エラーになる
async function main() {
  const user = await prisma.user.delete({ // 実行時エラー
    where: {
      id: 99,
    },
  });
  console.log(user);
}

マイグレーション

モデルの追加・変更を行う場合は、スキーマファイルを更新して、再びprisma migrateを行えばいい。
今回はUserモデルにisAdminフィールドを足すことにする。

model User {
  id      Int     @id @default(autoincrement())
  name    String
  isAdmin Boolean
}

しかしこの状態で% yarn run prisma migrate dev --name add-is-adminを実行すると、エラーになってしまう

Error:
⚠️ We found changes that cannot be executed:

  • Step 0 Added the required column `isAdmin` to the `User` table without a default value. There are 3 rows in this table, it is not possible to execute this step.

UserにはisAdminが必須になるように変更を行おうとしたが、既にUserテーブルにはレコードが 3 つあり、それらは当然isAdminを持っていないため、エラーになってしまう。
対応方法はいくつかあるが、今回はデフォルト値を設定して解決することにする。

スキーマファイルを編集し、isAdminにデフォルト値を設定する。

model User {
  id      Int     @id @default(autoincrement())
  name    String
  isAdmin Boolean @default(false)
}

再度% yarn run prisma migrate dev --name add-is-adminを実行すると、今度は上手くいく。

データベースを調べてみると、新しいモデルの定義が反映されていることを確認できる。

mysql> DESC User;
+---------+--------------+------+-----+---------+----------------+
| Field   | Type         | Null | Key | Default | Extra          |
+---------+--------------+------+-----+---------+----------------+
| id      | int          | NO   | PRI | NULL    | auto_increment |
| name    | varchar(191) | NO   |     | NULL    |                |
| isAdmin | tinyint(1)   | NO   |     | 0       |                |
+---------+--------------+------+-----+---------+----------------+
3 rows in set (0.00 sec)

mysql> SELECT * FROM User;
+----+-------+---------+
| id | name  | isAdmin |
+----+-------+---------+
|  1 | Alice |       0 |
|  2 | Bob   |       0 |
|  3 | Carol |       0 |
+----+-------+---------+
3 rows in set (0.00 sec)

node_modules/.prisma/client/index.d.tsの型定義も更新されているので、引き続き型の恩恵を受けながら開発することができる。

/**
 * Model User
 * 
 */
export type User = {
  id: number
  name: string
  isAdmin: boolean
}

API サーバの構築

最後に実践として、Prisma を使った簡単な API サーバを構築してみる。

仕様は以下のようにする。

  • /userGETメソッドでリクエストすると、全てのUserが返ってくる
  • /user/{ID}GETメソッドでリクエストすると、idフィールドがIDUserが返ってくる
  • /userPOSTメソッドでリクエストすると、Userが新規作成され、そのUserが返ってくる

まず、Node.js の型定義ファイルをインストールする。

% yarn add -D @types/node@16

次に、src/db.tsという名前のファイルを作成する。このファイルでは、Prisma クライアントでデータベース操作を行う。

// src/db.ts
import { PrismaClient, User } from "@prisma/client";

const prisma = new PrismaClient();

export async function getUser(id: number): Promise<User | null> {
  return await prisma.user.findUnique({
    where: {
      id,
    },
  });
}

export async function getAllUsers(): Promise<User[]> {
  return await prisma.user.findMany();
}

export async function createUser({ name }: { name: string }): Promise<User> {
  return await prisma.user.create({
    data: {
      name,
    },
  });
}

次に、src/index.tsでサーバについて記述していくのだが、まずは「全てのUserの取得」にのみ、対応させる。

import http from "http";

import { getAllUsers } from "./db";

function resNotFound(res: http.ServerResponse) {
  res.writeHead(404, {
    "Content-Type": "application/json",
  });
  res.write(JSON.stringify({ message: "Not Found" }) + "\n");
  res.end();
}

function resData(res: http.ServerResponse, data: unknown) {
  res.writeHead(200, {
    "Content-Type": "application/json",
  });
  res.write(JSON.stringify(data) + "\n");
  res.end();
}

http
  .createServer(async (req, res) => {
    if (req.url === undefined || !req.url.startsWith("/user")) {
      resNotFound(res);
      return;
    }

    switch (req.method) {
      case "GET": {
        switch (true) {
          case /^\/user$/.test(req.url): {
            const allUsers = await getAllUsers();
            resData(res, { data: allUsers });
            break;
          }
          default: {
            resNotFound(res);
            break;
          }
        }
        break;
      }

      default: {
        res.writeHead(405, {
          "Content-Type": "application/json",
        });
        res.write(JSON.stringify({ message: "Method Not Allowed" }) + "\n");
        res.end;
        break;
      }
    }
  })
  .listen(8080);

% yarn run ts-node-dev src/index.tsでサーバが起動するので、早速リクエストしてみる。

% curl http://localhost:8080/user
{"data":[{"id":1,"name":"Alice","isAdmin":false},{"id":2,"name":"Bob","isAdmin":false},{"id":3,"name":"Carol","isAdmin":false}]}

問題なく全てのUserを取得できた。

次に、idを指定して特定のUserを取得できるようにする。

以下の変更を加えてサーバを起動し直す。

@@ -1,6 +1,6 @@
 import http from "http";

-import { getAllUsers } from "./db";
+import { getAllUsers, getUser } from "./db";

 function resNotFound(res: http.ServerResponse) {
   res.writeHead(404, {
@@ -33,6 +33,12 @@
             resData(res, { data: allUsers });
             break;
           }
+          case /^\/user\/[0-9]+$/.test(req.url): {
+            const id = Number(req.url.slice(6));
+            const user = await getUser(id);
+            resData(res, { data: user });
+            break;
+          }
           default: {
             resNotFound(res);
             break;

そうすると、個別のUserを取得できるようになる。

% curl http://localhost:8080/user/1
{"data":{"id":1,"name":"Alice","isAdmin":false}}
% curl http://localhost:8080/user/2
{"data":{"id":2,"name":"Bob","isAdmin":false}}
% curl http://localhost:8080/user/9
{"data":null}

最後に、Userを作成できるようにする。
以下の変更を加えてサーバを起動し直せばよい。

@@ -1,6 +1,7 @@
 import http from "http";
+import { URLSearchParams } from "url";

-import { getAllUsers, getUser } from "./db";
+import { getAllUsers, getUser, createUser } from "./db";

 function resNotFound(res: http.ServerResponse) {
   res.writeHead(404, {
@@ -10,6 +11,14 @@
   res.end();
 }

+function resBadRequest(res: http.ServerResponse) {
+  res.writeHead(400, {
+    "Content-Type": "application/json",
+  });
+  res.write(JSON.stringify({ message: "Bad Request" }) + "\n");
+  res.end();
+}
+
 function resData(res: http.ServerResponse, data: unknown) {
   res.writeHead(200, {
     "Content-Type": "application/json",
@@ -47,6 +56,33 @@
         break;
       }

+      case "POST": {
+        switch (true) {
+          case /^\/user$/.test(req.url): {
+            let data = "";
+            req.on("data", (chunk) => {
+              data += chunk;
+            });
+            req.on("end", async () => {
+              const params = new URLSearchParams(data);
+              const name = params.get("name");
+              if (name === null) {
+                resBadRequest(res);
+                return;
+              }
+              const user = await createUser({ name });
+              resData(res, { data: user });
+            });
+            break;
+          }
+          default: {
+            resNotFound(res);
+            break;
+          }
+        }
+        break;
+      }
+
       default: {
         res.writeHead(405, {
           "Content-Type": "application/json",

POSTメソッドでUserを作成できるようになっている。

% curl -d name="Dave" http://localhost:8080/user
{"data":{"id":5,"name":"Dave","isAdmin":false}}
% curl http://localhost:8080/user
{"data":[{"id":1,"name":"Alice","isAdmin":false},{"id":2,"name":"Bob","isAdmin":false},{"id":3,"name":"Carol","isAdmin":false},{"id":5,"name":"Dave","isAdmin":false}]}

今回は対応しなかったが、アップデートや削除も同じ要領で実装すればよい。

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を検索すると情報を取得できる。