Fastify+PrismaでJWT認証付きREST-APIサーバーを作る
今現在、Node.jsでREST-APIを作ろうとすると以下のスタックが良さそうだと思った。
- Fastify
- Prisma
- fastify-jwt
Fastifyの選定理由
- Expressを普段から使っているが、Fastifyの方がパフォーマンスが良いらしい
- NestJSも触ってみたが、ぼくのユースケースに対しては大げさすぎる気がした
なお、GraphQLを使うならmercurius
というのが良さそう。
(しかしRESTから乗り換えるほどの利点を感じなかった)
所感
- FastifyはモダンなExpressという感じで癖はない。特に感動とかはないけど、TypeScriptの型サポートがいい感じ。
- Prismaはとても開発体験がよい、すばらしい。
- JWTは楽チン
- fastify-swaggerがとてもいい感じなのだが、JSON-schemeを書かないとならないのがちょっとだるい。
- Nginx
- PostgreSQL
- Fastify
上記をdocker-composeで構築する。
version: '3'
services:
nginx:
image: nginx
ports:
- '80:80'
volumes:
- ./web:/usr/share/nginx/html
- ./nginx:/etc/nginx/conf.d
depends_on:
- fastify
networks:
- server
fastify:
build: ./api
volumes:
- ./api:/usr/src/app
ports:
- 3000:3000
tty: true
command: bash -c "npm install && npm run dev"
env_file:
- .env
networks:
- server
db:
image: postgres:13.3
environment:
POSTGRES_USER: docker
POSTGRES_PASSWORD: docker
ports:
- 5432:5432
volumes:
- db:/var/lib/postgresql
networks:
- server
volumes:
db:
networks:
server:
driver: bridge
おなじみのTODOアプリを作ってみようと、下記のスキーマを書いてみる。
.prismaファイルは独自のシンタックスだが、それなりに直感的である
User:Taskが1:nの関係で、書き方はやや独特
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
password String
tasks Task[]
}
model Task {
id String @id @default(uuid())
title String
completed Boolean @default(false)
user User @relation(fields: [userId], references: [id])
userId String
}
seedデータはこんな感じ(なくてもいい)
import { hashSync } from 'bcrypt';
const userData: Prisma.UserCreateInput[] = [
{
email: 'test@test',
password: hashSync('testpassword', 10),
tasks: {
create: [
{
title: 'test',
},
],
},
},
];
なお書き忘れていたがpackage.json
は下記
必要ないのも混じっていたりします
{
"name": "rest-fastify",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"dev": "ts-node src/index.ts"
},
"dependencies": {
"@fastify/cors": "^7.0.0",
"@fastify/jwt": "^5.0.1",
"@prisma/client": "3.14.0",
"@sinclair/typebox": "^0.23.5",
"@types/bcrypt": "^5.0.0",
"bcrypt": "^5.0.1",
"fastify": "3.29.0",
"fastify-swagger": "^5.2.0"
},
"devDependencies": {
"@types/node": "16.11.38",
"prisma": "3.14.0",
"ts-node": "10.8.0",
"ts-node-dev": "^2.0.0",
"typescript": "4.7.2"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}
fastifyコンテナでPrismaをinit/migrate/seedしていく
npx prisma init
npx prisma migrate dev --name init
npx prisma db seed
雑にメインコードを貼り付ける
/signin
のエラーハンドリングが雑なのはご愛敬
// ./src/index.ts
import { Prisma, PrismaClient } from '@prisma/client';
import fastify from 'fastify';
// @ts-ignore
import fastifySwagger from 'fastify-swagger';
import fastifyJwt from '@fastify/jwt';
import fastifyCors from '@fastify/cors';
import routes from './routes';
import { compare } from 'bcrypt';
const app = fastify();
const prisma = new PrismaClient();
app.register(fastifyJwt, {
secret: 'supersecret',
});
app.register(fastifySwagger, {
routePrefix: '/docs',
openapi: {},
exposeRoute: true,
});
app.register(fastifyCors, {
origin: 'localhost',
methods: '*',
});
app.register(routes.users, { prefix: 'users' });
app.register(routes.tasks, { prefix: 'tasks' });
app.post<{ Body: { email: string; password: string } }>(
'/signin',
async (req, reply) => {
const { email, password } = req.body;
console.log(email, password);
const user = await prisma.user.findUnique({
where: {
email,
},
});
if (user === null) {
reply.send({ error: '404' });
return;
}
if (await compare(password, user.password)) {
// some code
const token = app.jwt.sign({ userId: user.id });
reply.send({ token });
} else {
reply.send({ error: '401' });
}
},
);
app.listen(3000, '0.0.0.0', (err) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`
🚀 Server ready at: http://localhost:3000
⭐️ See sample requests: http://pris.ly/e/ts/rest-fastify#3-using-the-rest-api`);
});
下記でセキュリティ的に問題ないかは未確認
ドキュメントには色々書いてあるので、もう少し正しいやり方がありそう
(この記事ではひとまず動けばええねん精神でお送りします)
JWTはこう初期化し
import fastifyJwt from '@fastify/jwt';
app.register(fastifyJwt, {
secret: 'supersecret',
});
こう発行する
const token = app.jwt.sign({ userId: user.id });
期限などを設定する方法は調べていない(けど必ずある)
JWT認証をさせたいエンドポイントで下記のように書くと、トークンが付与されていない・不正だと401エラーとなる
fastify.addHook('onRequest', async (request, reply) => {
try {
await request.jwtVerify();
} catch (err) {
reply.send(err);
}
});
import fastify, {
FastifyInstance,
FastifyRegisterOptions,
RegisterOptions,
FastifyPluginCallback,
} from 'fastify';
import { Prisma, PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';
const prisma = new PrismaClient();
export const users: FastifyPluginCallback = (
fastify: FastifyInstance,
opts: FastifyRegisterOptions<RegisterOptions>,
done: (err?: Error | undefined) => void,
) => {
fastify.addHook('onRequest', async (request, reply) => {
try {
await request.jwtVerify();
} catch (err) {
reply.send(err);
}
});
fastify.post<{
Body: {
email: string;
password: string;
tasks: Prisma.TaskCreateInput[];
};
}>(`/`, async (req, res) => {
const { email, password, tasks } = req.body;
const taskData = tasks?.map((task: Prisma.TaskCreateInput) => {
return { title: task?.title };
});
const result = await prisma.user.create({
data: {
email,
password,
tasks: {
create: taskData,
},
},
});
res.send(result);
});
fastify.get('/', async (req, res) => {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
},
});
res.send(users);
});
fastify.get<{ Params: { id: string } }>('/:id', async (req, res) => {
const { id } = req.params;
const users = await prisma.user.findUnique({
where: {
id,
},
select: {
id: true,
email: true,
},
});
res.send(users);
});
fastify.delete<{ Params: { id: string } }>('/:id', async (req, res) => {
const { userId } = await req.jwtDecode();
const { id } = req.params;
if (userId !== id) {
res.send({ err: 401 });
}
const users = await prisma.user.delete({
where: {
id,
},
});
res.send(users);
});
fastify.get<{ Params: { userId: string } }>(
'/:userId/tasks',
async (req, res) => {
const { userId } = req.params;
const task = await prisma.task.findMany({
where: {
userId,
},
});
res.send(task);
},
);
fastify.post<{ Body: { title: string }; Params: { userId: string } }>(
'/:userId/tasks',
async (req, res) => {
const { title } = req.body;
const { userId } = req.params;
const result = await prisma.task.create({
data: {
title,
user: {
connect: {
id: userId,
},
},
},
});
res.send(result);
},
);
done();
};
./tasks
エンドポイント
import fastify, {
FastifyInstance,
FastifyRegisterOptions,
RegisterOptions,
FastifyPluginCallback,
} from 'fastify';
import { Prisma, PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';
const prisma = new PrismaClient();
export const tasks: FastifyPluginCallback = (
fastify: FastifyInstance,
opts: FastifyRegisterOptions<RegisterOptions>,
done: (err?: Error | undefined) => void,
) => {
fastify.addHook('onRequest', async (request, reply) => {
try {
await request.jwtVerify();
} catch (err) {
reply.send(err);
}
});
fastify.put<{
Params: { id: string };
Body: { title?: string; completed?: boolean };
}>('/:id', async (req, res) => {
const { id } = req.params;
const { title, completed } = req.body;
const task = await prisma.task.update({
data: {
title,
completed,
},
where: {
id,
},
});
res.send(task);
});
fastify.delete<{ Params: { id: string } }>('/:id', async (req, res) => {
const { id } = req.params;
const task = await prisma.task.findUnique({
where: {
id,
},
});
if (task === null) {
res.send({ err: 404 });
return;
}
const { userId } = await req.jwtDecode();
if (userId !== task.userId) {
res.send({ err: 401 });
return;
}
await prisma.task.delete({
where: {
id,
},
});
res.send(task);
});
done();
};
./src/routes/index.ts
import { users } from './users';
import { tasks } from './tasks';
export default { users, tasks };
以上のようにエンドポイントを個別ファイルに分割しておいて
下記のようにregister()することで、ルーティングごとにファイルを分割できましたとさ
import routes from './routes';
app.register(routes.users, { prefix: 'users' });
app.register(routes.tasks, { prefix: 'tasks' });
/signin
で認証・JWTをもらう
curl -X POST -H 'Content-Type: application/json' -d '{"email":"test@test", "password":"testpassword"}' http://localhost:3000/signin
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI3MTYyNDQ0ZC1mOGVjLTRhMmMtYW以下略"}
/users
でユーザー一覧を受け取る
JWTをAuthorizationヘッダーに添える
curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI3MTYyNDQ0ZC1mOGVjLTRhMmMtYWMyNC1jZ以下略' http://localhost:3000/users
[{"id":"7162444d-f8ec-4a2c-ac24-cf7ffe1567e8","email":"test@test"}]
なおJWTがない場合はちゃんと401エラー
curl http://localhost:3000/users
{"statusCode":401,"error":"Unauthorized","message":"No Authorization was found in request.headers"}
JWT.ioでJWTを改竄してみると、ちゃんとエラー(userIdをいじってみた)
curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1以下略' http://localhost:3000/users
{"statusCode":401,"error":"Unauthorized","message":"Authorization token is invalid: The token signature is invalid."}
ちなみにfastify-swaggerを入れているので、http://localhost:3000/docs
でOpenAPI準拠のAPIドキュメントが勝手に出来上がっている。ただし、上記コードの状態では、エンドポイントの種類以上の情報は表示されない。各エンドポイントでJSON-Schemeを書いてあげれば良いらしい。
以上でTODO管理ができそうなAPIサーバーができました。
次はフロントエンドを開発してTODOアプリを作ってみます。