🌱

prisma と express でつくる REST API

2021/07/23に公開

はじめに

前から気になっていた Prisma を触ってみました。
公式ドキュメントを読みながら、練習として Prisma + Express で REST API を作ってみました。
自分と同じように入門してみたい人の参考になれば幸いです。

ソースコードはこちらになります。
https://github.com/yamosan/prisma-rest-api

環境構築

プロジェクトの作成

$ npm init -y

パッケージのインストール

必要なパッケージを予めインストールしていきます。
ついでに、 ESLint と Prettier も入れておきます。
npm-run-all は npm scripts の記述がシンプルになるため入れていますが、必須ではありません。

$ npm i -D typescript ts-node nodemon @types/node
$ npm i -D eslint prettier eslint-config-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser
$ npm i -D npm-run-all

各種設定ファイル

それぞれお好みで設定していきます。

tsconfig.json
tsconfig.json
{
  "extends": "@tsconfig/node14/tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "resolveJsonModule": true,
    "baseUrl": "./src"
  },
  "include": ["src/**/*.ts", "src/**/*.js"],
  "exclude": ["node_modules"]
}
tsconfig.eslint.json
tsconfig.eslint.json
{
  "extends": "./tsconfig.json",
  "include": ["**/*.ts", "**/*.js"]
}
.eslintrc.json
.eslintrc.json
{
  "root": true,
  "env": {
    "es2021": true,
    "node": true
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "sourceType": "module",
    "ecmaVersion": 12,
    "project": "./tsconfig.eslint.json"
  },
  "plugins": [],
  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
  "rules": {}
}
.prettierrc
.prettierrc
{
  "singleQuote": false,
  "semi": true,
  "printWidth": 120
}

npm scripts の設定

package.json
{
  "scripts": {
    "dev": "nodemon --watch './src/**/*.ts' --exec ts-node --files ./src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "check-types": "tsc --noEmit",
    "lint": "run-p -l -c --aggregate-output lint:*",
    "lint:eslint": "eslint src/**/*.ts",
    "lint:prettier": "prettier --check .",
    "fix": "run-s -l -c fix:eslint fix:prettier",
    "fix:eslint": "eslint --fix src/**/*.ts",
    "fix:prettier": "prettier --write ."
  },
}

prisma のインストール

postgres

DB に接続する流れは、公式の Getting started に分かりやすくまとめられています。

今回は postgres Docker イメージを使います。

docker-compose.yml
version: "3"

services:
  db:
    image: postgres
    volumes:
      - db-data:/var/lib/postgresql/data
    ports:
      - 5433:5432
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydb

volumes:
  db-data:

prisma の初期化

$ npx prisma init

これを実行すると、プロジェクトルートに .env ファイルと schema.prisma を含む prisma ディレクトリが作成されたかと思います。

ディレクトリ構成
.
├── node_modules
├── prisma
│   └── schema.prisma
├── .env
├── .eslintrc.json
├── .prettierrc
├── docker-compose.yml
├── package-lock.json
├── package.json
├── tsconfig.eslint.json
└── tsconfig.json

これらを以下のように編集します。

.env
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQL Server and SQLite.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

- DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
+ DATABASE_URL="postgresql://postgres:password@localhost:5433/mydb"
prisma/prisma.schema
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

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

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

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

User モデルを追加してみました。今回はこのモデルに対して CURD の実装をしていきます。
DATABASE_URL は、先程 docker-compose.yml で指定した値によって変えます。

↑ DATABASE_URL について

prisma と DB の接続確認

$ docker-compose up -d
$ npx prisma migrate dev --name init

ここでエラーが出なければマイグレーションが成功しています。

まだデータが何もないのでデータを数件登録してみましょう。

prisma studio でデータを登録する

prisma には、 prisma studio という機能があります。

簡単に説明すると、GUI でデータベース上のデータを閲覧・編集することができるものです。

これを使ってデータを登録してみます。

$ npx prisma studio

All Models から User を選択し、適当に 2 件レコードを登録します。

これで実際に 2 件のデータが登録されました。

prisma client でデータを確認する

次に、先程追加したデータをクライアント側で操作してみたいと思います。
ここで prisma client を使います。

src/index.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  const allUsers = await prisma.user.findMany()
  console.log(allUsers)
}

main()
  .catch((e) => {
    throw e
  })
  .finally(async () => {
    await prisma.$disconnect()
  })

findMany() は条件にあったデータを全件取得します。今回は条件を指定していないので「User 全件」となります。それでは実行してみましょう。

$ npx ts-node src/index.ts

[
  { id: 1, name: 'taro', email: 'taro@example.com' },
  { id: 2, name: 'jiro', email: 'jiro@example.com' }
]

先程登録したデータを実際に取得することができました!

seed を登録する

先程は prisma studio を使って初期データを登録しましたが、毎回これで登録するのは不便です。
prisma にはコマンドで seed データを投入できる機能があります。

  • prisma/seed.ts
  • export function seed

のいずれかを設定すると、

$ npx prisma db seed

で seed データの登録をしてくれます。便利!

それでは、実際に登録していきます。

prisma/seed.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();

async function main() {
  const taro = await prisma.user.upsert({
    where: { email: "taro@example.com" },
    update: {},
    create: {
      email: "taro@example.com",
      name: "taro",
    },
  });

  const jiro = await prisma.user.upsert({
    where: { email: "jiro@example.com" },
    update: {},
    create: {
      email: "jiro@example.com",
      name: "jiro",
    },
  });
  console.log({ taro, jiro });
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });
$ npx prisma db seed --preview-feature

Running seed from prisma/seed.ts ...
Result:
{
  taro: { id: 1, name: 'taro', email: 'taro@example.com' },
  jiro: { id: 2, name: 'jiro', email: 'jiro@example.com' }
}

🌱  Your database has been seeded.

upsert は

  • 同じデータが存在していたら更新
  • 存在しなければ新規作成

という挙動をします。

prisma client の詳細は 公式へ

prisma コマンドを npm scripts にする

最後にこれまでのコマンド は npm script にした方が便利なのでまとめます。

package.json
  "scripts": {
+   "prisma:generate": "prisma generate",
+   "prisma:migrate": "prisma migrate dev",
+   "prisma:seed": "prisma db seed --preview-feature",
+   "prisma:dev": "run-s -l prisma:generate prisma:migrate prisma:seed"
  }

REST API の作成

長い準備でしたが、ここから実際に prisma client を使って rest API を作っていきます。
具体的には、http://localhost:3000/usersからユーザを取得したり登録できるようにします。
今回は Expressjs で作っていきいますが、公式では他にも沢山の例が載っています。

https://github.com/prisma/prisma-examples/

とても参考になるので是非一度見てみて下さい。

Express のインストール

$ npm i express
$ npm i -D @types/express

Express のセットアップ

src/app.ts
import express from "express";

const app = express();

app.get("/", (req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("hello express\n");
});

export default app;
src/index.ts
import app from "./app";

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`REST API server ready at: http://localhost:${PORT}`);
});

それでは実際にサーバーを起動してみます。

$ npm run dev

# 別のタブで
$ curl -X GET http://localhost:3000
hello express

実際にテキストが返ってきていれば OK です。

User の CRUD を作成

実際に user を返す API を作っていきます。
まず最初に GET /users で全てのユーザーを返すようにしてみましょう。

src/app.ts
  import express from "express";
+ import { PrismaClient } from "@prisma/client";

+ const prisma = new PrismaClient();

  const app = express();
+ app.use(express.json());

  app.get("/", (req, res) => {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("hello express\n");
  });

+ app.get("/users", async (req, res) => {
+   const users = await prisma.user.findMany();
+   res.json({ users });
+ });

  export default app;
curl -X GET http://localhost:3000/users

{"users":[{"id":1,"name":"taro","email":"taro@example.com"},{"id":2,"name":"jiro","email":"jiro@example.com"}]}%

先程、prisma db seed で登録したユーザを実際に API から取得することができました!

userController

GET /users の例では、 src/app.ts に直接書きました。このままでも問題ないですが見づらくなってしまうので、userController として実装を切り出します。

src/app.ts
  import express from "express";
- import { PrismaClient } from "@prisma/client;
+ import userController from "./controllers/userController";

- const prisma = new PrismaClient();

  const app = express();
  app.use(express.json());

+ app.use("/users", userController);

  app.get("/", (req, res) => {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("hello express\n");
  });

- app.get("/users", async (req: Request, res: Response) => {
-   const users = await prisma.user.findMany();
-   res.json({ users });
- });

  export default app;
src/controllers/userController.ts
import { PrismaClient } from "@prisma/client";
import { Router, Request, Response } from "express";

const prisma = new PrismaClient();
const router = Router();

// GET /users
router.get("/", async (req: Request, res: Response) => {
  const users = await prisma.user.findMany();
  res.json({ users });
});

export default router

app.use("/users", userController)
によって、/users 以下のルーティングをマッピングしています。これで User に対する API を一つのファイルに集約させることができます。

それでは他のアクションも記述していきます。 GET /users のハンドラーの下に追記していきます。

GET /users/:id
// GET /users/:id
router.get("/:id", async (req: Request, res: Response) => {
  const user = await prisma.user.findUnique({
    where: { id: parseInt(req.params?.id) },
  });
  res.json({ user });
});
POST /users
// POST /users
router.post("/", async (req: Request, res: Response) => {
  const { name, email } = req.body;
  const user = await prisma.user.create({
    data: { name, email },
  });
  res.json({ user });
});
PUT /users/:id
// PUT /users/:id
router.put("/:id", async (req: Request, res: Response) => {
  const { name, email } = req.body;
  const user = await prisma.user.update({
    where: { id: parseInt(req.params?.id) },
    data: { name, email },
  });
  res.json({ user });
});
DELETE /users/:id
// DELETE /users/:id
router.delete("/:id", async (req: Request, res: Response) => {
  const user = await prisma.user.delete({
    where: { id: parseInt(req.params?.id) },
  });
  res.json({ user });
});

これで User の REST API の完成です!
実際に curl コマンドでうまく動作しているか確認してみてください。

curl で検証

GET /users

$ curl -X GET http://localhost:3000/users

{"users":[{"id":1,"name":"taro","email":"taro@example.com"},{"id":2,"name":"jiro","email":"jiro@example.com"}]}%

GET /users/:id

$ curl -X GET http://localhost:3000/users/1

{"user":{"id":1,"name":"taro","email":"taro@example.com"}}

POST /users

$ curl -X POST http://localhost:3000/users \
  -H 'content-type: application/json' \
  -d '{"name": "hanako","email": "hanako@example.com"}'

{"user":{"id":3,"name":"hanako","email":"hanako@example.com"}}

# id:3が増えている
$ curl -X GET http://localhost:3000/users
{"users":[{"id":1,"name":"taro","email":"taro@example.com"},{"id":2,"name":"jiro","email":"jiro@example.com"},{"id":3,"name":"hanako","email":"hanako@example.com"}]}%

PUT /users/:id

$ curl -X PUT http://localhost:3000/users/3 \
  -H 'content-type: application/json' \
  -d '{"email": "hanakosan@example.com"}'

{"user":{"id":3,"name":"hanako","email":"hanakosan@example.com"}}

# id:3が更新されている
$ curl -X GET http://localhost:3000/users/3
{"user":{"id":3,"name":"hanako","email":"hanakosan@example.com"}}

DELETE /users/:id

$ curl -X DELETE http://localhost:3000/users/3

{"user":{"id":3,"name":"hanako","email":"hanakosan@example.com"}}

# id:3が削除されている
$ curl -X GET http://localhost:3000/users
{"users":[{"id":1,"name":"taro","email":"taro@example.com"},{"id":2,"name":"jiro","email":"jiro@example.com"}]}%

テストの実装(jest)

jest のインストール

API はこれで完成しましたが、
最後に簡単なテストを追加していきます。

$ npm i -D jest ts-jest supertest @types/jest @types/supertest

supertest は express のテストを簡単にしてくれるライブラリです。

jest.config.js
module.exports = {
  roots: ["<rootDir>/src"],
  testMatch: ["**/__tests__/**/*.+(ts|tsx|js)", "**/?(*.)+(spec|test).+(ts|tsx|js)"],
  transform: {
    "^.+\\.(ts|tsx)$": "ts-jest",
  },
};
package.json
  "scripts": {
+  "test": "NODE_ENV=test jest",
  },

cleanup 関数の実装

少し余談になりますが、prisma には db を再構築する prisma db reset コマンドが用意されています。
このコマンドは順番に

  1. db の再構築
  2. prisma migrate
  3. prisma db seed

を実行します。

コード内でもこれと似たようなことができないかと探していたら Blitz.js の実装を見つけました。

参考

Blitz では、PrismaClient のラッパーを提供しているようです。
実装としては migrate resetを呼び出しているだけですので、今回はこちらを参考にして resetDatabase 関数を作ります。

src/utils/resetDatabase.ts
import { spawn } from "child_process";

export default async function () {
  if (process.env.NODE_ENV === "test") {
    await new Promise((resolve, reject) => {
      const process = spawn("prisma", ["migrate", "reset", "--force", "--skip-generate", "--skip-seed"]);
      process.on("close", (code) => {
        if (code === 0) {
          resolve(0);
        } else {
          reject(code);
        }
      });
    });
  }
}

呼び出し側ではこう使います。

import resetDatabase from "../utils/resetDatabase"

describe("userController test", () => {
  beforeEach(async () => {
    await resetDatabase();
  });
}

こうすることで、テスト毎にデータを削除してくれるので、前のテストのデータを意識せずにテストを書くことができるかと思います。(毎回削除するので実行は遅くなります。)

テストの実装

一つ一つ説明しても長くなってしまうので完成形を表示します。
supertest を用いることで express に対するテストが簡潔に書けます。

https://github.com/visionmedia/supertest

自分なりに一通り書いてみましたが、テストを書き慣れていないので、もっといい書き方があれば教えてください。

src/controllers/userController.spec.ts
src/controllers/userController.spec.ts
import resetDatabase from "../utils/resetDatabase";
import supertest from "supertest";
import app from "../app";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

describe("userController test", () => {
  beforeEach(async () => {
    await resetDatabase();
  });
  afterAll(async () => {
    await prisma.$disconnect();
  });

  describe("GET /users", () => {
    test("response with success", async () => {
      for (let i = 0; i < 3; i++) {
        await prisma.user.create({ data: { id: i, name: `user${i}`, email: `user${i}@example.com` } });
      }
      const users = await prisma.user.findMany();

      const response = await supertest(app).get("/users");
      expect(response.status).toBe(200);
      expect(response.body.users).toEqual(users);
    });
  });

  describe("GET /users/:id", () => {
    test("response with success", async () => {
      const user = await prisma.user.create({ data: { id: 1, name: "user1", email: "user1@example.com" } });

      const response = await supertest(app).get("/users/1");
      expect(response.status).toBe(200);
      expect(response.body.user).toEqual(user);
    });
  });

  describe("POST /users", () => {
    test("response with success", async () => {
      const body = { id: 1, name: "user1", email: "user1@example.com" };
      const response = await supertest(app).post("/users").send(body);
      expect(response.status).toBe(200);
      expect(response.body.user).toEqual(body);

      const users = await prisma.user.findMany();
      expect(users.length).toBe(1);
    });
  });

  describe("PUT /users/:id", () => {
    test("response with success", async () => {
      await prisma.user.create({ data: { id: 1, name: "user1", email: "user1@example.com" } });

      const body = { name: "updated", email: "updated@example.com" };
      const response = await supertest(app).put("/users/1").send(body);
      expect(response.status).toBe(200);
      expect(response.body.user.name).toEqual(body.name);
      expect(response.body.user.email).toEqual(body.email);

      const after = await prisma.user.findUnique({ where: { id: 1 } });
      expect(after?.name).toEqual(body.name);
      expect(after?.email).toEqual(body.email);
    });
  });

  describe("DELETE /users/:id", () => {
    test("response with success", async () => {
      const user = await prisma.user.create({ data: { id: 1, name: "user1", email: "user1@example.com" } });

      const response = await supertest(app).delete("/users/1");
      expect(response.status).toBe(200);
      expect(response.body.user).toEqual(user);

      const users = await prisma.user.findMany();
      expect(users.length).toBe(0);
    });
  });
});

おわりに

今回は prisma を使って REST API を実装してみました。
prisma は自動で型がついてくれるのがとても便利でした。Blits も気になっているのでまた触ってみたいと思います。
もっとこうした方がいいとか間違ってる箇所がありましたら指摘していただけると幸いです 🙇🏻‍♂️

Discussion