やりたいこと
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 している。みたいなものにした。
// 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 基本形
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
もする。
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
などとすると、パスから取得することができる。
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
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
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
const resUser = await prisma.user
.findMany({ (条件) })
- app/pages/posts/read.ts
- app/pages/users/read.ts
findUnique
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
戻り値の型を定義
import { Post } from '@prisma/client';
export type PostType = Post & {
author: {
id: number;
userName: string;
imageUrl: string | null;
};
_count: {
bookmarks: number;
};
};
こんな感じで元の Type に include で追加したものを追加する。
Update
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
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 で使うよと言う必要がある。
generator client {
provider = "prisma-client-js"
previewFeatures = ["interactiveTransactions"]
}
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 認証
前に書いたので、そちらを参照。
今回は、app/handlers/Cors.ts
, BearerAuth.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
公式ドキュメント通りに進める。
package.json に以下を追加
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
必要なものを install する
$ yarn add ts-node @types/node --dev
@types/node
は元々入っていたが、入っていない場合は、インストールが必要。
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
でも良いし、いくつか解決策があるみたいだが、今回は、こちらを参考にした。
{
"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
を使う
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 するなり、ご自由に。
おしまい。