本番環境でAPIサービスを6ヶ月間運用し、毎日数千件のリクエストをさばいてきた経験から気づいたことがあります。一番難しいのはコードを書くことではなく、最初から正しくAPIを設計することです。エンドポイントの命名ミス、一貫性のないレスポンス構造、v1からのバージョニング漏れ——これらはすべて、スケールする際に非常に痛い技術的負債になります。
この記事は、そのAPIをコーディングしながらリファクタリングした経験をまとめたものです。ルートの設計、入力バリデーション、JWT認証、そして持続可能なAPIバージョン管理まで解説します。
「とりあえず動けばいい」REST APIの現実的な問題
多くのプロジェクトはExpressをこんな感じで始めます:server.jsファイルを作り、いくつかのルートを追加して、動けばOK。プロトタイプの段階ではそれで構いません——しかしプロジェクトが大きくなると、あらゆることが崩れ始めます:
- ルートが混在し、どのエンドポイントが何をしているかわからない
- バリデーションがない → クライアントが何を送っても受け付けてしまう
- レスポンスフォーマットを変更する必要が出たとき、利用中のすべてのクライアントを壊さなければならない
- 認証処理があちこちにコピペされ、一箇所直しても別の箇所を見落とす
以前、50Kラインのコードベースをリファクタリングしたことがあります。最大の教訓:始める前に十分なテストカバレッジが必要だということ。二番目の教訓(同じくらい痛かった):最初からAPI構造を正しく設計しなければならないということです。クライアントが使用中のAPIをリファクタリングするのは非常にリスクが高く、わずかな破壊的変更でユーザーのモバイルアプリ全体がクラッシュする可能性があります。
押さえておくべきコアコンセプト
RESTはHTTP + JSONだけではない
多くのAPIが自らを「RESTful」と称していますが、実際にはHTTP上のJSONに過ぎません。REST(Representational State Transfer)にはよく見落とされる原則がいくつかあります:
- リソースベースのURL:
/getUser?id=123ではなく/users/123 - 適切なHTTPメソッド:GETは読み取り、POSTは作成、PUT/PATCHは更新、DELETEは削除
- ステートレス:各リクエストは必要な情報をすべて含み、サーバーサイドのセッションに依存しない
- 一貫したレスポンス構造:成功でもエラーでも常に同じフォーマットを返す
バージョニング——最もよく見落とされるもの
APIをバージョン管理する一般的な方法は3つあります:URLパス(/api/v1/)、クエリ文字列(?version=1)、HTTPヘッダー(Accept: application/vnd.api+json;version=1)。私はURLパスを使っています。理由はシンプルです:見やすく、デバッグしやすく、Postmanやcurlなどのツールがいきなり使えて追加設定が不要だからです。
実践:最初から正しいAPIを構築する
プロジェクト構成
まず、フォルダ構成を明確にしましょう——よく使う構成はこちらです:
project/
├── src/
│ ├── routes/
│ │ └── v1/
│ │ ├── users.js
│ │ └── posts.js
│ ├── middleware/
│ │ ├── auth.js
│ │ └── validate.js
│ ├── controllers/
│ ├── models/
│ └── app.js
├── package.json
└── .env
最初からバージョニングありでExpressをセットアップする
// src/app.js
const express = require('express');
const app = express();
app.use(express.json());
// v1ルートをマウント
const v1Router = require('./routes/v1');
app.use('/api/v1', v1Router);
// 破壊的変更があった場合、v1に影響を与えずv2を追加できる
// const v2Router = require('./routes/v2');
// app.use('/api/v2', v2Router);
module.exports = app;
// src/routes/v1/index.js
const router = require('express').Router();
router.use('/users', require('./users'));
router.use('/posts', require('./posts'));
module.exports = router;
ミドルウェアで入力をバリデーションする
コントローラー内でバリデーションしないでください——再利用できるよう個別のミドルウェアに分離しましょう。joiまたはzodライブラリをよく使います:
npm install joi
// src/middleware/validate.js
const Joi = require('joi');
const validate = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(400).json({
success: false,
errors: error.details.map(d => d.message)
});
}
req.validatedBody = value;
next();
};
module.exports = validate;
// src/routes/v1/users.js
const router = require('express').Router();
const Joi = require('joi');
const validate = require('../../middleware/validate');
const auth = require('../../middleware/auth');
const createUserSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
name: Joi.string().min(2).max(50).required()
});
router.post('/', validate(createUserSchema), async (req, res) => {
// req.validatedBodyはサニタイズ済み
const { email, name, password } = req.validatedBody;
// ... ユーザーを作成
});
router.get('/me', auth, async (req, res) => {
// ログインが必要なルート
});
module.exports = router;
JWT認証
npm install jsonwebtoken bcryptjs
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const auth = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, message: '認証トークンがありません' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ success: false, message: 'トークンが無効または期限切れです' });
}
};
module.exports = auth;
ログイン時のトークン生成部分:
// ログインコントローラー内
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
async function login(req, res) {
const { email, password } = req.validatedBody;
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ success: false, message: 'メールアドレスまたはパスワードが間違っています' });
}
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
success: true,
data: { token, user: { id: user.id, name: user.name, email: user.email } }
});
}
一貫したレスポンスフォーマット
エンドポイントごとに異なるフォーマットを返すと、フロントエンドチームに嫌われます。シンプルなルール:常にsuccessを持ち、常にdataまたはmessageを持つ。バリデーションエラーの場合はerrorsを配列形式で追加し、クライアントがフィールドごとに表示できるようにする:
// 成功時
{
"success": true,
"data": { ... },
"meta": { "page": 1, "total": 50 } // ページネーション用
}
// エラー時
{
"success": false,
"message": "ユーザーが見つかりません",
"errors": ["メールアドレスはすでに存在します", "パスワードが短すぎます"] // オプション
}
毎回書き直さないようにヘルパーを作成します:
// src/utils/response.js
exports.ok = (res, data, meta) =>
res.json({ success: true, data, ...(meta && { meta }) });
exports.fail = (res, status, message, errors) =>
res.status(status).json({ success: false, message, ...(errors && { errors }) });
集中型エラーハンドリング
未処理のエラーが発生しないよう、app.jsの末尾にグローバルエラーハンドラーを追加します:
// src/app.jsの末尾
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.status || 500;
res.status(status).json({
success: false,
message: status === 500 ? 'サーバー内部エラーが発生しました' : err.message
});
});
本番環境でしか学べないこと
ローカルで動くのは別の話です。本番環境に「教育」してもらって初めて理解できた点をいくつか紹介します:
- スタックトレースをクライアントに露出しない——サーバー側でログを残し、500エラーには一般的なメッセージだけを返す
- レート制限は必須——
express-rate-limitを使い、ブルートフォース攻撃を防ぐため認証エンドポイントには最大100リクエスト/15分を設定する - リクエスト/レスポンスのログを
morganで初日から記録する——本番で問題が起きた深夜3時、ログだけが頼りになります - Postmanコレクションでエンドポイントをテストしてリポジトリにコミット——新しくチームに加わったメンバーが感謝してくれます
- まずバリデーションと認証のミドルウェアのユニットテストを書く——ここが最も重要な部分で、バグがあるとAPI全体に影響します
バージョニングについてもう一つ:v2を追加するとき、v1をすぐに削除しないでください。v1を少なくとも3〜6ヶ月間維持し、廃止予定を明確に通知し、クライアントが移行するための十分な時間を与えましょう。この点を正しく実践したことで、多くのクレームを避けられました。シンプルですが、見落としがちです。
Node.js + ExpressはREST APIとして信頼できるコンビです——十分な柔軟性があり、エコシステムが大きく、どこにでもデプロイできます。差をつけるのはフレームワークではなく、後でリファクタリングするのではなく、最初から丁寧に設計することです。
