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

## やりたいこと

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

含まれるもの

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

完成品:

https://github.com/kurab/next-prisma-api-sample

## 準備

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 している。みたいなものにした。


```typescript: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 基本形

```typescript: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``` もする。

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

export const prisma = new PrismaClient();
```

### エラーハンドリング

```typescript
    .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``` などとすると、パスから取得することができる。

```typescript: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

```typescript: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

```typescript: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

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

- app/pages/posts/read.ts
- app/pages/users/read.ts

### findUnique

```typescript: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

```typescript
orderBy: { updatedAt: 'desc' },
```

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

```typescript
orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }],
```


- app/pages/posts/read.ts
- app/pages/users/[userId].ts
- app/pages/users/read.ts


### join

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

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

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

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

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

```typescript
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``` で指定する。

```typescript
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


### 戻り値の型を定義

```typescript: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

```typescript: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

```typescript: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 で使うよと言う必要がある。

```typescript:app/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
  previewFeatures = ["interactiveTransactions"]
}
```

```typescript: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エラーとたたかった記録](https://qiita.com/kurab/items/84573b3092c8db4ba750)

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

```typescript: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](https://www.prisma.io/docs/guides/database/seed-database)

package.json に以下を追加

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

必要なものを install する

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

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

```typescript: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``` でも良いし、いくつか解決策があるみたいだが、今回は、[こちら](https://github.com/TypeStrong/ts-node/issues/922#issuecomment-673155000)を参考にした。

```json: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件ずつ取ってくるみたいなことをやりたい場合。

https://www.prisma.io/docs/concepts/components/prisma-client/pagination

```skip```, ```take``` を使う

```typescript: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 でガーっと入れた。


## 成果物

https://github.com/kurab/next-prisma-api-sample

```
├── 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 するなり、ご自由に。

- [Next.js の Product Build を docker-compose で動かしたい](https://qiita.com/kurab/items/706a94fe71f9aaefd8c2)
- [動的な Next.js を Azure App Service に Deploy する](https://qiita.com/kurab/items/46352a65ee946eee9c76)

おしまい。