こんにちは、新卒エンジニアの鈴木です!
ExpressでAPIを作ってみたいけど、
「どこまでできたら“ちゃんと作れた”って言えるんだろう?」
「データベースとつなぐって、なんだか急にむずかしそう……」
そんなふうに感じている人に向けて、この記事ではExpress × PostgreSQL × Prismaを使って、APIをひとつずつ組み立てていきます。
基本的なCRUD操作からユーザー認証まで、ステップごとに動かしながら進めていくので、「そろそろバックエンドの仕組みを理解したいな」という人にぴったりです。
1. 開発環境の準備
Node.jsのインストール
Expressの利用にはNode.jsをインストールし、npmを利用できる環境を作っておく必要があります。
まだインストールできていない人は、前回の記事を確認してください。 Node.jsとは?できることや始め方を初心者向けに解説
PostgreSQLのインストール
こちらからインストールしてください!
PostgreSQLは、世界中で使われているオープンソースのDBです。一番の特徴は、高い安定性と安全性です。MVCC(Multi-Version Concurrency Control)という仕組みによって複数のユーザーが同時にデータにアクセスしても、データの整合性が崩れにくいようになっています。
並行処理が多いシステムや金融や医療などの高い安全性が必要なシステムにも選ばれています。
Postmanの導入
また今回はデバッグにPostmanを使用します。
Postmanは、APIの開発・テスト・デバッグを効率化するためのツールでHTTPリクエストの送信やレスポンスの確認はもちろん、パラメータや認証情報の追加などをGUIで行うことができます。
開発中のAPIを試したいときや、外部APIと連携するときによく使われるAPIにかかわる仕事をするなら必須と言ってもいいツールです。
2. Express.jsでAPI開発を始めよう
プロジェクトの初期設定
API開発をするプロジェクトフォルダを作成し、必要なツールを準備しましょう。
mkdir wordbook //wordbookフォルダを作成する cd wordbook //wordbookフォルダに移動 npm init -y //プロジェクトの初期化 npm install express dotenv bcrypt jsonwebtoken @prisma/client //必要なパッケージをインストールする。
これでpackage.jsonが作成され、プロジェクトを開始できます!
Hello WorldでAPIの基本を理解する
最初といえば「HelloWorld」。ということで、さっそく書いていきます。
ルートにserver.jsを作成し、以下のコードを記述してください。
const express = require('express'); const port = 3000; const app = express(); app.use(express.json()); app.get('/', (req, res) => { res.status(200).json({message: 'Hello World'}) }); app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); });
これらがExpressの基本的な書き方です。サーバーの役割もあるので前回のコードとかなり似ていますね。
前回にはなかったルーティングについて解説していきます。以下該当部分です。
app.use(express.json()); app.get('/', (req, res) => { res.status(200).json({message: 'Hello World'}) });
app.use(express.json())を使用することで、JavaScriptオブジェクトとJSONの相互変換が自動的に行われます。これにより、オブジェクトを操作するだけで簡単にAPIを実装することができます。
app.get(path, callback())はルーティングと呼ばれる機能です。ルーティングとは、リクエストのパスに応じて実行する処理を振り分ける仕組みのことです。
上記の例では、localhost:3000/に対するGETリクエストを定義しており、実際にリクエストが送信されると第二引数のコールバック関数が実行されます。この関数内でレスポンスを返すための処理を記述します。
res.status(200).json()でレスポンスを定義します。ステータスコード200と、レスポンスボディをjson()メソッドに指定することで、JSON形式のレスポンスを返すことができます。
Postmanを使ってAPIをテストする
コードが書けたのでテストしてみましょう。まずはサーバーを起動します。ターミナルに以下のコマンドを実行してください。
node server.js
Server running on http://localhost:3000と表示されていれば成功です。
次はPostmanで実際にリクエストをしてみましょう。
上記のようにmethodをGET、URLを入力し「Send」を押すと、HelloWorldとレスポンスが返ってきました!! 大成功です!!
基本的な記述は理解できましたでしょうか? それでは単語APIの作成を始めます。
3. Prismaでデータベースと連携する
PostgreSQLでデータベースとテーブルを作成
まずはもととなるデータベースを作成し、JavaScriptから操作をするためにPrismaの初期化をやっていきます。
▲Geminiに作成してもらったER図
これをもとにデータベースを構築します。コマンドプロンプトで以下のコマンドとSQLを実行してください。
psql -h localhost -U postgres
このコマンドはPostgreSQL に接続するための基本コマンドです。
-h のあとには接続先(今回は localhost、つまり自分のパソコン)を指定し、-U には接続時に使用するユーザー名を指定します。
パスワードを聞かれるので、インストールのときに設定したパスワードを入力してください。
無事接続できたらデータベースとテーブルを作ります。SQLの説明は省きますが、ログイン情報用のusersテーブルと単語用のwordsテーブルを用意します。
-- データベースの作成 CREATE DATABASE wordbook; \c wordbook -- usersテーブル作成 CREATE TABLE users ( id SERIAL PRIMARY KEY, username VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- wordsテーブル作成 CREATE TABLE words ( id SERIAL PRIMARY KEY, user_id INT NOT NULL, word VARCHAR(255) NOT NULL, description TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); --usersに一行追加 INSERT INTO users (username, password) VALUES ('hogehoge', 'password'); --wordsに一行追加 INSERT INTO words (user_id, word, description) VALUES (1, 'GET', 'クライアントがウェブサーバーのリソースを取得するために使用するHTTPプロトコルのリクエストメソッド');
SQLの解説はしませんが、userテーブルとwordsテーブルを作成し、APIテスト用にそれぞれ一行追加しています。
Prismaの概要と導入手順
Prismaとは?
Prismaは、Node.jsやTypeScriptのプロジェクトで使えるORMツールです。ORM とは、プログラミング言語のコードとデータベースをつなぐ役割をする仕組みのことで、データベース操作をもっと簡単で直感的にしてくれます。
Prisma を使うことで、SQLを直接書かなくても、JavaScript や TypeScript のコードでデータベースとやりとりができるようになります。SQL にあまり慣れていない人にとって、非常に使いやすい選択肢となっています。
初期化と.envファイルの設定
PrismaがNode.jsで使えますが、データベースの情報を伝えてあげる必要があります。以下のコマンドを実行してください。
npx prisma init
initだなんて、いかにも準備してくれそうなコマンドですねー。
インストールが終わると.envとschema.prismaが作成されます。.envファイルは環境変数を定義するためのファイルです。
アプリに必要な機密情報(APIキー、データベースの接続情報など)をこのファイルにまとめておくことで、コードに直接書かずに済み、セキュリティ面や管理面でも便利になります。
DATABASE_URL="postgresql://user:password@localhost:port/wordbook?schema=public" PORT=3000
DATABASE_URLのuser,password,portには、PostgreSQLをインストールしたときに設定したものを入力してください。
schema.prisma は、Prisma が使う「設計図ファイル」です。
このファイルに書いた内容をもとに、Prisma はデータベースとやりとりするコード(Prisma Client)を自動で生成してくれます。ここにモデル定義をしていきます。が……なんとデータベースからスキーマをもらってくることができます。
上記のコマンドを実行することでschema.prismaが更新され、スキーマができます!!
Prismaでスキーマを定義する
DBスキーマの自動生成と修正
generator client { provider = "prisma-client-js" output = "../generated/prisma" //<- outputがある場合削除してください。 } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model users { id Int @id @default(autoincrement()) username String @unique @db.VarChar(255) password String @db.VarChar(255) created_at DateTime? @default(now()) @db.Timestamp(6) updated_at DateTime? @default(now()) @db.Timestamp(6) words words[] } model words { id Int @id @default(autoincrement()) user_id Int word String @db.VarChar(255) description String created_at DateTime? @default(now()) @db.Timestamp(6) updated_at DateTime? @default(now()) @db.Timestamp(6) users users @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction) @@index([user_id], map: "idx_user_id") }
自動で書いてくれるのは便利ですね。しかしながらこれは個人的に気に入らないので修正します。
テーブル名を複数形にしたためモデル名も複数形になっています。ここが気に入りません。
モデルって「一人のユーザー」や「一つの単語」の形を決める設計書なのに複数形の違和感がとんでもなくあります。なので”僕は”単数系に直します。
model User { //単数形に修正 id Int @id @default(autoincrement()) username String @unique @db.VarChar(255) password String @db.VarChar(255) created_at DateTime? @default(now()) @db.Timestamp(6) updated_at DateTime? @default(now()) @db.Timestamp(6) words Word[] @@map("users") // 実際のDBのテーブル名 } model Word { //単数形に修正 id Int @id @default(autoincrement()) user_id Int word String @db.VarChar(255) description String created_at DateTime? @default(now()) @db.Timestamp(6) updated_at DateTime? @default(now()) @db.Timestamp(6) users User @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction) @@index([user_id], map: "idx_user_id") @@map("words") // 実際のDBのテーブル名 }
これですっきり。
Prisma Clientの生成
最後にPrisma Clientを生成します。これもコマンド一つできます。
npx prisma generate
これでgeneratedファイルができました。これを使用することでNode.jsからデータベースの操作が可能になります。
4. 単語API(CRUD)を実装する
環境構築ができたので、いよいよコードを書いていきます。
ディレクトリ構成とルーティングの準備
複数のファイルを書くことになるため、最初にディレクトリ構造だけ共有します。
▲Geminiに作成してもらったディレクトリ構造
さきほどAPIルーティングの書き方について解説しましたが、1つのAPI機能に対して1つのルーティングを書きます。現状のようにapp.jsにすべてのルーティングを書いていくと、規模が大きくなればなるほど煩雑になってしまいます。なので単語関連のAPIは単語専用のファイル、認証APIは認証専用のファイルというように分けてあげます。
プロジェクトルートにsrcフォルダを作成し、src/app.js、src/routes/words.js、src/routes/auth.jsを作成してください。そしてapp.jsに以下のコードを記述してください。
// src/app.js const express = require('express'); require('dotenv').config(); const authRoutes = require('./routes/auth'); const wordRoutes = require('./routes/words'); const app = express(); const PORT = process.env.PORT || 3000; app.use(express.json()); // 認証ルート app.use('/api/auth', authRoutes); // 単語関連ルート app.use('/api/words', wordRoutes); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });
このようにすることで、ルーティングを制御することができます。
src/routes/words.jsとsrc/routes/auth.jsには以下のコードを記述します。
// src/routes/words.js const express = require('express'); const { PrismaClient } = require('@prisma/client'); // Prismaクライアントをインポート const prisma = new PrismaClient(); //Prismaをインスタンス化 const router = express.Router(); module.exports = router; //src/app.jsで使用できるようにする
これで準備は完了です。
以下がこれから作成するAPIの一覧です。
全件取得(GET /words)
やっとAPIが書けますね。ここからはルーティングを書いてコールバック関数がすこし違うくらいなのでサクサクいきましょう。
src/routes/words.jsのexportよりも上に、以下のコードを記述してください。
// 単語一覧取得 router.get('/', async (req, res) => { try { const words = await prisma.word.findMany(); res.json(words); } catch (error) { console.error('Failed to retrieve words:', error); res.status(500).json({ message: 'Failed to retrieve words' }); } });
prisma.word.findMany(); は、PrismaClientを使って、wordテーブル(モデル)の全レコードを取得するメソッドです。なんて簡単。
Postmanで実行してみましょう。
しっかりと表示されていますね。
詳細取得(GET /words/:id)
次は1つだけ単語を取得するAPIです。単語一覧取得の下に以下のコードを記述してください。
// 単語取得 router.get('/:id', async (req, res) => { const { id } = req.params; const wordId = Number(id) try { const word = await prisma.word.findUnique({ where: { id: wordId }, }); if (!word) { return res.status(404).json({ message: 'Word not found' }); } res.json(word); } catch (error) { console.error('Failed to retrieve word:', error); res.status(500).json({ message: 'Failed to retrieve word' }); } });
特定の単語を取得する場合、クライアントがほしいと思っている単語をpathに書いてリクエストを送るのが一般的です。つまり:idというのはクライアントが動的に変えてリクエストを送るということになります。
これを受け取るにはreq.paramsから取得する必要があります。受け取ったwordIdを元にfindUnique()でidと一致するデータを取得しています。
リクエストを送るとこのようになります。
wordsのデータが一つしかないので、あまり変わっていないように見えますが、一覧が配列なのに対して、詳細取得がobjectになっているのが一件のみになっています。
追加(POST /words)
// 単語追加 router.post('/', async (req, res) => { const { word, description } = req.body; const userId = 1; //認証を作ったら変更する if (!word || !description) { return res.status(400).json({ message: 'Word and description are required' }); } try { const newWord = await prisma.word.create({ data: { user_id: userId, word, description }, }); res.status(201).json(newWord); } catch (error) { console.error('Failed to create word:', error); res.status(500).json({ message: 'Failed to create word' }); } });
追加をする場合はリクエストから情報をもらわなければなりません。今回はwordとdiscriptionとuser_idです。しかしuser_idは認証をしたあとに追加するので、一度1で仮置きをしておきます。
create()で必要なデータをdataに入れることで、INSERTができるようになります。
リクエストbodyに必要な情報を入力し、Sendすると追加されました!! ばっちりですね。
一覧APIをもう一度実行すると、しっかりと追加されているので確認してみてください。
しかしエンジニアたるもの、エラーを想定しなければなりません。wordやdescriptionなしでリクエストしても正常にエラーメッセージが出ることも確認しといてください。
更新(PUT /words/:id)
単語の編集は1件取得と追加を組み合わせたようなイメージです。
pathからもらってきたidに対して、リクエストbodyの情報をもとに更新します。
// 単語更新 router.put('/:id', async (req, res) => { const { id } = req.params; const wordId = Number(id); const { word, description } = req.body; const userId = 1; try { const existingWord = await prisma.word.findUnique({ where: { id: wordId }, }); if (!existingWord) { return res.status(404).json({ message: 'Word not found' }); } if (existingWord.user_id !== userId) { return res.status(403).json({ message: 'Forbidden: You do not own this word.' }); } const updatedWord = await prisma.word.update({ where: { id: wordId }, data: { word, description }, }); res.json(updatedWord); } catch (error) { console.error('Failed to update word:', error); res.status(500).json({ message: 'Failed to update word' }); } });
自分が作った単語は自身でしか編集できないようにするため、user_idが一致するかも検証しています。こちらは認証が終わったあとに検証しましょう。
追加と大きく違うことは、updateなので必要な情報だけでも更新できることです。たとえばdescriptionのみ更新したい場合は、リクエストにwordを含める必要がなくなります。
削除(DELETE /words/:id)
単語の削除は、更新とほとんど変わりません。
なぜなら必要な1件を取得したあと削除するだけだからです。リクエストbodyから情報を受け取らないぶん、簡単になっているといっていいでしょう。
// 単語削除 router.delete('/:id', async (req, res) => { const { id } = req.params; const wordId = Number(id); const userId = 1; try { const existingWord = await prisma.word.findUnique({ where: { id: wordId }, }); if (!existingWord) { return res.status(404).json({ message: 'Word not found' }); } if (existingWord.user_id !== userId) { return res.status(403).json({ message: 'Forbidden: You do not own this word.' }); } await prisma.word.delete({ where: { id: wordId }, }); res.json({ message: 'Word deleted successfully' }); } catch (error) { console.error('Failed to delete word:', error); res.status(500).json({ message: 'Failed to delete word' }); } });
かなり似ていますね。こちらも該当のuserのみしか消去できないようにするのを忘れないようにしましょう。
無事削除できました。これでCRUDは完了です。
5. 認証機能を実装する
ここからは認証系のAPIを作成していきます。
ユーザー登録(サインアップ)
パスワードのハッシュ化処理
ユーザー登録といっても単語を登録するのとほとんど変わりません。SQL上ではただINSERTしているだけなので。
しかし、ユーザー認証となると話は違います。セキュリティに配慮する必要があります。なのでAPIを作成する前に、まずパスワードをハッシュ化する関数と、ログイン時にユーザーが入力したパスワードとDBに保存されているハッシュ化済みパスワードが一致するかを検証するユーティリティ関数を作成します。
// src/utils/passwordUtils.js const bcrypt = require('bcrypt'); //passwordをハッシュ化する const hashPassword = async (password) => { const saltRounds = 10; // ハッシュ化の強度 return bcrypt.hash(password, saltRounds); }; //入力したパスワードと、DBにあるハッシュ済みのパスワードを一致するか検証する const comparePassword = async (password, hash) => { return bcrypt.compare(password, hash); }; module.exports = { hashPassword, comparePassword, };
これらを使用しながら、ユーザー登録を実装します。
const { hashPassword, comparePassword } = require('../utils/passwordUtils'); //3行目に追加 // ユーザー登録(サインアップ) router.post('/signup', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ message: 'Username and password are required' }); } try { const hashedPassword = await hashPassword(password); await prisma.user.create({ data: { username, password: hashedPassword, }, }); res.status(201).json({ message: 'User registered successfully'}); } catch (error) { if (error.code === 'P2002') { // Prismaのエラーコード: Unique constraint failed return res.status(409).json({ message: 'Username already exists' }); } console.error('Signup error:', error); res.status(500).json({ message: 'Server error during signup' }); } });
こちらは単語登録とほぼ同じですね。DBに登録する直前でパスワードをハッシュ化するのを忘れずに。
またPrismaのエラーコードについても、ハンドリングすることによって、同じユーザー名を登録しようとした場合、エラーになるようにしています。
ちゃんと登録できました。
データベースを見てもパスワードがハッシュ化できていることが確認できます。いいね。
ログイン(トークン発行)
JWTの生成と検証
続いてログインAPIを作成します。
Webサイトでは、一度ログインするとしばらくの間は再ログインが不要になります。これは、ログイン情報が保持されているためです。単語のAPIをたたいてるたびにログインしているようじゃ、よいアプリを作ることはできません。
そこで利用されるのがToken(トークン)と呼ばれるものです。Tokenを使用することで、バックエンド側では「このトークンは正しいか」をチェックするだけで済むので、ユーザーは毎回ログインする必要がなくなります。
なので、まずはTokenの発行と検証するための処理を作ります。
// src/utils/jwt.js const jwt = require('jsonwebtoken'); const generateToken = (userId) => { return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '1h' }); // 有効期限を1時間に設定 }; const verifyToken = (token) => { return jwt.verify(token, process.env.JWT_SECRET); }; module.exports = { generateToken, verifyToken, };
.envの設定
今回はJWT(JSON Web Token)を使用しています。これの利用には秘密鍵が必要になりますので、.envに任意の文字列を記述しておいてください。
//.env JWT_SECRET="my-super-secret-key"
"my-super-secret-key"は例であり、第三者に悟られない複雑なものにしましょう。
準備が整ったところで、ログイン処理を記述していきます。
const { generateToken } = require('../utils/jwt'); //ファイル先頭に記述 // ログイン機能 router.post('/login', async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ message: 'Username and password are required' }); } try { const user = await prisma.user.findUnique({ where: { username }, }); if (!user) { return res.status(401).json({ message: 'Invalid credentials' }); } const isPasswordValid = await comparePassword(password, user.password); if (!isPasswordValid) { return res.status(401).json({ message: 'Invalid credentials' }); } const token = generateToken(user.id); res.status(200).json({ message: 'Login successful', token, userId: user.id }); } catch (error) { console.error('Login error:', error); res.status(500).json({ message: 'Server error during login' }); } });
まず最初に、username を使って該当するユーザーを検索しています。
ユーザーが見つかった場合は、入力されたパスワードが正しいかどうかを確認します。
ここで重要なのは、「ユーザーが存在しない場合」と「パスワードが間違っている場合」に同じエラーメッセージを返すようにしている点です。これにより、攻撃者に「そのユーザーが存在するのかどうか」を推測されるのを防ぐことができます。セキュリティの観点でも非常に大切な実装です。
無事にログインできました。
認証ミドルウェアの作成
ログインの処理が完成しトークンが発行できた今、仮置きしていたuser_idを修正しましょう。
しかしwords.jsでどのようにTokenからuser_idを取得するのでしょうか。これにはミドルウェアを使用します。ミドルウェアとは、リクエストとレスポンスの間に挟まって処理を行う関数のことです。ミドルウェアでリクエストから受け取ったTokenをもとに、user_idを返す処理を記述します。
// src/middlewares/authMiddleware.js const { verifyToken } = require('../utils/jwt'); const authMiddleware = (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ message: 'Invalid authorization header format' }); } const token = authHeader.split(' ')[1]; // "Bearer TOKEN" の形式を想定 if (!token) { return res.status(401).json({ message: 'Token is missing' }); } try { const decoded = verifyToken(token); req.userId = decoded.userId; // リクエストにユーザーIDを付与 next(); //レスポンスの処理へ } catch (error) { if (error.name === 'TokenExpiredError') { return res.status(401).json({ message: 'Token expired' }); } return res.status(401).json({ message: 'Invalid token' }); } }; module.exports = authMiddleware;
この authMiddleware は、JWTトークンによる認証を行うための共通ミドルウェアです。
リクエストヘッダーの Authorization をチェックし、 "Bearer トークン" の形式でトークンが送られているかを確認します。
トークンが存在し、かつ有効であれば req.userId にデコードしたユーザーIDを設定し、次の処理へ進みます。トークンが無効または期限切れの場合は、それぞれに応じた 401 エラーを返します。
APIに認証を適用する
さきほどのauthMiddlewareを使用し、認証が必要なAPIを完成させます。
userIdを使っている単語追加、単語編集、単語削除を以下のように変更してください。
router.post('/', async (req, res) => { ↓ router.post('/', authMiddleware, async (req, res) => {
const authMiddleware = require('../middlewares/authMiddleware'); //追加 router.post('/', async (req, res) => { //↓以下に修正 router.post('/', authMiddleware, async (req, res) => { const userId = 1; //認証を作ったら変更する //↓以下に修正 const userId = req.userId;
これでリクエスト認証の完成です。
テストするときは、ログイン時に発行されたTokenを、AuthorizationのBearerTokenに貼ることを忘れずに。
無事にuser_idが、2のユーザとして登録することができました!
これで完成です。
まとめ
本当にお疲れ様でした。かなりのボリュームがありましたが、API開発で期待通りのレスポンスが返ってくると本当にうれしいですよね。
まだCORSやセキュリティでやりたいことはありますが、バックエンドの基礎知識からExpress.jsの記述方法まではしっかり解説できたと思います。
次回はフロントエンドとのつなぎこみをやっていこうと思います。ご精読ありがとうございました。