28
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Next.js(TypeScript) で Prisma を使って API を作る

Last updated at Posted at 2022-02-23

やりたいこと

Next.js + TypeScript で Prisma を使って API を作る。いままで何となくできちゃっていたものを、きちんと整理してまとめた。JavaScript だとできちゃってた、TypeScript でも Any にしちゃえばできちゃってた、などなど厳密にやろうと思うと、割とちょろまかしていたことがたくさんあった。

含まれるもの

  • Slug
  • CRUD
  • Transaction
  • CORS
  • Bearer 認証
  • Seed
  • pagination

完成品:

準備

Docker で PostgreSQL を立ち上げ、Next.js を TypeScript で作り、Prisma を設定。適当にスキーマを定義する。

PostgreSQL は立ち上がっているものとする。docker-compose.yml 参照。

$ yarn create next-app app --typescript
$ cd app
$ yarn add @prisma/client
$ yarn add prisma --dev
$ npx prisma init

Prisma スキーマを定義する

モデルは、正直何でも良かったのだが、User がいて、Post があって、Post に対する Bookmark がある。それぞれ relation している。みたいなものにした。

prisma/schema.prisma
// 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")
}

model User {
  id        Int      @id @default(autoincrement()) @map("userId")
  userName  String   @unique
  imageUrl  String?  @db.VarChar(256)
  posts     Post[]
  bookmarks Bookmark[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map(name: "users")
}

model Post {
  id        Int      @id @default(autoincrement()) @map("postId")
  title     String
  content   String
  authorId  Int
  // if author is deleted, post record will be also deleted
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  bookmarks Bookmark[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map(name: "posts")
}

model Bookmark {
  id Int      @id @default(autoincrement()) @map("bookmarkId")
  postId Int?
  //if post is deleted, bookmark record will NOT be also deleted
  post    Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
  userId Int
  //if user is deleted, bookmark record will be deleted
  user   User @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())

  @@map(name: "bookmarks")
}

PostgreSQL では、データ型が色々あるが、Prisma では、Int, String... などで、もう少しきちんと型を定義したい場合は、 @db.VarChar(256) などとして設定できる。

relation の onDelete は、relation 先が削除された際の挙動だ。
Bookmark では、
Cascade: User が削除されると、Bookmark のレコードも削除される。
SetNull: Post が削除されると、Null がセットされる。Null をセットするので、postId?, Post? としている。そうしないとエラーが出る。別に消えちゃっても良いんだけど、違う挙動を見たかったので。

$ npx prisma migrate dev

API 基本形

app/pages/api/xxx.ts
import type { NextApiRequest, NextApiResponse, NextApiHandler } from 'next';
import { prisma } from '../../lib/Prisma';
import type { 戻り値の型 } from '../../types/XxxxType';
import type { ErrorType } from '../../types/MessageType';

type Props = {
  (パラメータの型)
}

const postHandler = async (
  req: NextApiRequest,
  res: NextApiResponse<(戻り値の型) | (エラー時の戻り値の型: ErrorType)>
) => {
  const {(パラメータ)}: Props = req.body;
  (パラメータのバリデーション)

  let statusCode = 200;
  const resXxxx = await prisma.(モデル名)
    .(何らかの処理)({
      (何らかの条件)
    })
    .catch((err) => {
      statusCode = 500;
      console.log(err);
      return { error: 'Failed to read user' };
    })
    .finally(async () => {
      await prisma.$disconnect();
    });

  res.status(statusCode).json(resXxxx);
};

const handler: NextApiHandler = (req, res) => {
  switch (req.method) {
    case 'POST':
      postHandler(req, res);
      break;
    default:
      return res.status(405).json({ error: 'Method not allowed.' });
  }
};

export default handler;

req.method は、用途に合わせて、GET, POST, PUT, DELETE などとする。この基本形の例では、POST メソッド以外は受け付けないようになっている。なくても良い。

prisma インスタンス

毎回 new しても良いが、$disconnect を忘れると、コネクションが張りっぱなしになり、コネクションがどんどん増えていくので、app/lib/Prisma.ts を読み込むようにする。$disconnect もする。

app/lib/Prisma.ts
import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient();

エラーハンドリング

    .catch((err) => {
      statusCode = 500;
      console.log(err);
      return { error: 'Failed to read user' };
    })

.catch で、throw したり return res.status(500).json({ error: 'xxx' }) などとすると、型指定した戻り値の型に合わなくなってしまう。.catch がないと、エラーが起きた時にうんともすんとも言わなくなる。と言うわけで、簡単ではあるが、こんな感じにした。

Slug

[userId].ts などとすると、パスから取得することができる。

app/pages/api/users/[userId].ts
  const { userId } = Array.isArray(req.query) ? req.query[0] : req.query;

こんな感じで取得する。取れる値は、string 型になるので、注意。
当然と言えば当然だが、同一ディレクトリには一個しか設定できないので、階層は注意する必要がある

├── [userId].ts
├── [postId].ts <- これはできない
└── post
    └── [postId].ts <- 階層を変える

サンプル

  • app/pages/bookmarks/post/[postId].ts
  • app/pages/bookmarks/[userId].ts
  • app/pages/posts/[postId].ts
  • app/pages/users/[userId].ts

Create

単純な Create

app/pages/api/users/create.ts
  let userBody: Prisma.UserCreateInput;
  userBody = {
    userName: userName,
    imageUrl: imageUrl ? imageUrl : '',
  };
  const resUser = await prisma.user
    .create({ data: userBody })
  • app/pages/users/create.ts

Relation がある Create

app/pages/api/posts/create.ts
  let postBody: Prisma.PostCreateInput;
  postBody = {
    title,
    content,
    author: { connect: { id: authorId } },
  };
  const resUser = await prisma.post
    .create({ data: postBody })

authorId は直接入れることができない。

  • app/pages/bookmarks/create.ts
  • app/pages/posts/create.ts

Read

findMay

app/pages/api/users/read.ts
  const resUser = await prisma.user
    .findMany({ (条件) })
  • app/pages/posts/read.ts
  • app/pages/users/read.ts

findUnique

app/pages/api/users/[userId].ts
  const resUser = await prisma.user
    .findUnique({
      where: { (検索条件) },
      (その他条件)
    })
  • app/pages/bookmarks/post/[postId].ts
  • app/pages/bookmarks/[userId].ts
  • app/pages/posts/[postId].ts
  • app/pages/users/[userId].ts

orderBy

orderBy: { updatedAt: 'desc' },

Order Key が複数ある場合は、[] に入れてあげる。

orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
  • app/pages/posts/read.ts
  • app/pages/users/[userId].ts
  • app/pages/users/read.ts

join

何も指定しないと取ってこないので、include する

include: {
  posts: true,
  bookmark: true,
},

など。ただし、これだと全部取ってきちゃうので、必要なものだけにしたい場合、select する。

include: {
  posts: {
    select: {
      id: true,
      title: true,
      createdAt: true,
    },
  },
  bookmarks: {
    select: { postId: true },
  },
}

など。orderBy を指定することもできる

include: {
  posts: {
    orderBy: [{ updatedAt: 'desc' }, {createdAt: 'desc'}],
    select: {...},
  },
}
  • app/pages/api/bookmarks/post/[postId].ts
  • app/pages/api/bookmarks/[userId].ts
  • app/pages/api/posts/[postId].ts
  • app/pages/api/posts/read.ts
  • app/pages/api/users/[userId].ts
  • app/pages/api/users/read.ts

count

count も include で指定する。

include: {
  _count: {
    select: {
      posts: true,
      bookmarks: true,
    },
  },
}
  • app/pages/api/posts/[postId].ts
  • app/pages/api/posts/read.ts
  • app/pages/api/users/[userId].ts
  • app/pages/api/users/read.ts

戻り値の型を定義

app/types/PostType.ts
import { Post } from '@prisma/client';

export type PostType = Post & {
  author: {
    id: number;
    userName: string;
    imageUrl: string | null;
  };
  _count: {
    bookmarks: number;
  };
};

こんな感じで元の Type に include で追加したものを追加する。

Update

app/pages/api/posts/update.ts
  const resPost = await prisma.post
    .update({
      where: { id: Number(id) },
      data: postBody,
    })
  • app/pages/api/posts/update.ts
  • app/pages/api/users/update.ts

Delete

app/pages/api/bookmarks/delete.ts
  const resBookmark = await prisma.bookmark
    .delete({ where: { id } })
  • app/pages/api/bookmarks/delete.ts
  • app/pages/api/posts/delete.ts
  • app/pages/api/users/delete.ts

Transaction

このモデルで Transaction もクソもないので、良い例が思いつかなかったんですが、User が Post を作成すると同時に Bookmark するという Transaction をば。
Bookmark には、postId が必要なので、作ったものを元に作成したい。この場合、Interactive Transaction 機能を使うことになる。これは、本日時点では、preview 機能となっているので、schema.prisma で使うよと言う必要がある。

app/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
  previewFeatures = ["interactiveTransactions"]
}
app/pages/api/transaction/create.ts
  const transaction = await prisma
    .$transaction(
      async (tx) => {
        let postBody: Prisma.PostCreateInput;
        postBody = {
          title,
          content,
          author: { connect: { id: userId } },
        };
        const post = await tx.post.create({ data: postBody });

        let bookmarkBody: Prisma.BookmarkCreateInput;
        const fakeId = 0;
        bookmarkBody = {
          //post: { connect: { id: post.id } },
          post: { connect: { id: fakeId } },
          user: { connect: { id: userId } },
        };
        const bookmark = await tx.bookmark.create({ data: bookmarkBody });

        return {
          post: post,
          bookmark: bookmark,
        };
      },
      {
        maxWait: 2000,
        timeout: 5000,
      }
    )
    .catch((err) => {
      statusCode = 500;
      console.log(err);
      return { error: 'Failed transaction' };
    })
    .finally(async () => {
      await prisma.$disconnect();
    });

これは、わざと失敗するようにしてある。ここで、注意点として、.catch を2つの create に付けてしまうと、トランザクション的には、create に失敗しても成功したことになってしまうため、ロールバックされない。

catch は、$transaction に対して付ける。

maxWait, timeout はそれぞれ default 値が書かれているので省略しても動作的には変わらない。

CORS と Bearer 認証

前に書いたので、そちらを参照。

Bearer認証付きAPIでCORSエラーとたたかった記録

今回は、app/handlers/Cors.ts, BearerAuth.ts に配置した。

app/pages/api/auth/access_token.ts
import { cors } from '../../../handlers/Cors';

...

export default cors(handler);

こんな感じで使う。
基本形では、Method を限定しており、Preflight Request の OPTIONS は受け付けないが、実行される順序として、cors/authenticated などが先に処理されるので、問題ない。

なお、CORS エラーとなる API を app/pages/api/corsfail.ts に置いた。 method は何でもよくて、header に Authorization というキーで、何でも良いので適当に値を入れる。POSTMAN や curl 等ではエラーにならないが、ブラウザから実行した場合、cross origin で CORS エラーになる。
app/pages/api/hello.ts は返却するものは同じだが、GET, POST 等で呼べば、simple request となり、preflight が走らず CORS エラーにはならない。

BearerAuth では jwt を利用しているので、それはインストールが必要。

yarn add jsonwebtoken
yarn add @types/jsonwebtoken --dev

Seed

公式ドキュメント通りに進める。

Seeding your database

package.json に以下を追加

"prisma": {
  "seed": "ts-node prisma/seed.ts"
},

必要なものを install する

$ yarn add ts-node @types/node --dev

@types/node は元々入っていたが、入っていない場合は、インストールが必要。

app/prisma/seed.ts
import { Prisma, PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

const main = async () => {
  let userBody: Prisma.UserCreateInput;
  userBody = {
    userName: 'admin',
    imageUrl: '/takt.png',
  };

  await prisma.user.upsert({
    where: { userName: 'admin' },
    update: {},
    create: userBody,
  });
};

main()
  .catch((err) => {
    console.error(err);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

例えばこんなもの。npx prisma migrate dev するたびに実行されるみたいなので、upsert を使っている。
なお、このファイルでは、 import はこのままでは使えない。
require でも良いし、いくつか解決策があるみたいだが、今回は、こちらを参考にした。

tsconfig.json
{
  "ts-node": {
    "compilerOptions": {
      "module": "commonjs"
    }
  },
  "compilerOptions": {
    ...
    "module": "esnext",
    ...
  },
}

コマンドでシード。

$ npx prisma db seed
Environment variables loaded from .env
Running seed command `ts-node prisma/seed.ts` ...
...

🌱  The seed command has been executed.

できた。

Pagination

10件ずつ取ってくるみたいなことをやりたい場合。

skip, take を使う

app/pages/api/users/pagenation/[pageNo].ts
  const { pageNo } = Array.isArray(req.query) ? req.query[0] : req.query;

  let statusCode = 200;
  const resUser = await prisma.user
    .findMany({
      orderBy: { id: 'asc' },
      skip: Number(pageNo * 10),
      take: 10,
      include: {
        posts: {
          orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
          take: 3,
          select: {
            id: true,
            title: true,
            createdAt: true,
          },
        },
...

10件ずつ取ってくるようにした。include でも使える。こういうことをやる場合は、orderBy を必ず入れよう。

  • app/pages/api/posts/pagination/[pageNo].ts
  • app/pages/api/users/pagination/[pageNo].ts

何件も登録するのが面倒だったので、seed.ts でガーっと入れた。

成果物

├── app
│   ├── handlers
│   │   ├── BearerAuth.ts // Bearer Authentication
│   │   └── Cors.ts       // CORS
│   ├── lib
│   │   └── Prisma.ts     // Prisma インスタンス(基本これを呼び、毎回 new しない)
│   ├── pages
│   │   ├── api // 各種 API
│   │   │   ├── auth
│   │   │   │   └── access_token.ts // JWT で TOKEN を発行(id, secret を POST する)
│   │   │   ├── bookmarks
│   │   │   │   ├── [userId].ts
│   │   │   │   ├── create.ts
│   │   │   │   ├── delete.ts
│   │   │   │   └── post
│   │   │   │       └── [postId].ts
│   │   │   ├── posts
│   │   │   │   ├── pagination
│   │   │   │   │   └── [pageNo].ts
│   │   │   │   ├── [postId].ts
│   │   │   │   ├── create.ts
│   │   │   │   ├── delete.ts
│   │   │   │   ├── read.ts
│   │   │   │   └── update.ts
│   │   │   ├── transaction
│   │   │   │   └── create.ts  // Transaction が失敗するようにしてある
│   │   │   ├── users
│   │   │   │   ├── pagination
│   │   │   │   │   └── [pageNo].ts
│   │   │   │   ├── [userId].ts
│   │   │   │   ├── create.ts  // ユーザが自分でアカウントを作成するようなサービスの場合、authenticated から外す
│   │   │   │   ├── delete.ts
│   │   │   │   ├── read.ts
│   │   │   │   └── update.ts
│   │   │   ├── corsfail.ts  // CORS エラーが起こる
│   │   │   └── hello.ts     // CORS エラーが起こらない
│   │   ├── _app.tsx
│   │   └── index.tsx
│   ├── prisma
│   │   ├── migrations/*
│   │   ├── schema.prisma
│   │   └── seed.ts          // admin ユーザを最初に入れる
│   ├── public/*
│   ├── types
│   │   ├── BookmarkType.ts
│   │   ├── MessageType.ts
│   │   ├── PostType.ts
│   │   ├── TokenType.ts
│   │   ├── TransactionType.ts
│   │   └── UserType.ts
│   ├── package.json
│   ├── tsconfig.json
│   └── .env
├── pgdata/*
├── .env
└── docker-compose.yml

ここまで分かっていれば、あとは作業ですね。たぶん。
DB の中身は、npx prisma studio で見る。

あとは、docker-compose で動かすなり、Azure に Deploy するなり、ご自由に。

おしまい。

28
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?