MongoDB: RESTful API設計:CRUD操作とバリデーション
最終更新:2026-08-26
RESTful APIはモダンWebアプリケーションの基盤—マスターすれば保守性の高いAPIサービスを構築できます。
1. 学習内容
- RESTful API設計原則
- CRUD操作の標準パターン
- 入力バリデーション(Joi)
- ページネーション、検索、フィルタリング
- 権限制御(RBAC)
- Expressルーティングのベストプラクティス
graph LR
Client[クライアント] -->|GET /api/products| List[商品一覧<br/>ページネーション]
Client -->|GET /api/products/:id| Detail[商品詳細]
Client -->|POST /api/products| Create[商品作成<br/>管理者のみ]
Client -->|PUT /api/products/:id| Update[商品更新<br/>管理者のみ]
Client -->|DELETE /api/products/:id| Delete[商品削除<br/>管理者のみ]
List -->|200 OK| Client
Detail -->|200 OK / 404| Client
Create -->|201 Created| Client
Update -->|200 OK| Client
Delete -->|204 No Content| Client
style Create fill:#d4edda
style Update fill:#cce5ff
style Delete fill:#fff3cd
2. RESTful API設計原則
概念概要: RESTful APIはHTTPプロトコルに基づいたリソース中心のAPI設計スタイルです。各URLがリソースを表し、HTTPメソッド(GET/POST/PUT/DELETE)がリソースへの操作を表します。統一的な設計規約に従うことで、APIは直感的に理解可能で、保守しやすく、拡張しやすくなります。
RESTful設計の核心原則:
| 原則 | 説明 | 例 |
|---|---|---|
| リソース中心 | URLは名詞(リソース)で表現 | /products, /orders |
| HTTPメソッド | 操作はHTTPメソッドで表現 | GET=読み取り、POST=作成 |
| ステートレス | 各リクエストは独立 | セッション状態を持たない |
| 一貫性 | 統一的なレスポンス形式 | { success, data, meta } |
| バージョニング | APIバージョンを明示 | /api/v1/products |
| HATEOAS | ハイパーメディアリンク | 次ページURLをレスポンスに含む |
HTTPメソッドとCRUDの対応:
| HTTPメソッド | CRUD操作 | 意味 | 成功ステータス | 失敗ステータス |
|---|---|---|---|---|
| GET | Read | リソース取得 | 200 OK | 404 Not Found |
| POST | Create | リソース作成 | 201 Created | 400 Bad Request |
| PUT | Update | リソース全体更新 | 200 OK | 400/404 |
| PATCH | Update | リソース部分更新 | 200 OK | 400/404 |
| DELETE | Delete | リソース削除 | 204 No Content | 404 |
// === RESTful URL設計例 ===
// 商品リソース
GET /api/v1/products // 商品一覧(ページネーション付き)
GET /api/v1/products/:id // 商品詳細
POST /api/v1/products // 商品作成
PUT /api/v1/products/:id // 商品全体更新
PATCH /api/v1/products/:id // 商品部分更新
DELETE /api/v1/products/:id // 商品削除
// ネストしたリソース(商品に対するレビュー)
GET /api/v1/products/:id/reviews // 商品のレビュー一覧
POST /api/v1/products/:id/reviews // レビュー作成
GET /api/v1/reviews/:id // レビュー詳細
DELETE /api/v1/reviews/:id // レビュー削除
3. CRUD操作の標準パターン
概念説明: CRUD操作はRESTful APIの基本機能です。一貫したパターンに従うことで、コードの可読性と保守性が向上します。標準パターンには:エラー処理、成功レスポンス形式、データ検証、ページネーション処理が含まれます。
標準レスポンス形式:
// 成功レスポンス(一覧)
{
"success": true,
"data": [...],
"meta": { "page": 1, "limit": 20, "total": 100, "pages": 5 }
}
// 成功レスポンス(詳細・作成・更新)
{
"success": true,
"data": { ... }
}
// エラーレスポンス
{
"success": false,
"error": "エラーメッセージ",
"details": [...] // 任意
}
(1) 商品一覧API(ページネーション + 検索 + フィルタ)
// controllers/productController.js
exports.listProducts = async (req, res) => {
const {
page = 1,
limit = 20,
category,
minPrice,
maxPrice,
search,
sort = 'createdAt',
order = 'desc'
} = req.query;
// クエリ条件を構築
const query = { isActive: true };
if (category) query.category = category;
if (minPrice || maxPrice) {
query.price = {};
if (minPrice) query.price.$gte = +minPrice;
if (maxPrice) query.price.$lte = +maxPrice;
}
if (search) {
query.$or = [
{ title: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
];
}
// 並び替え条件
const sortOption = {};
sortOption[sort] = order === 'desc' ? -1 : 1;
// 並列実行(find + count)
const [products, total] = await Promise.all([
Product.find(query)
.select('sku title price thumbnail rating category')
.sort(sortOption)
.limit(+limit)
.skip((+page - 1) * +limit)
.lean(),
Product.countDocuments(query)
]);
res.json({
success: true,
data: products,
meta: {
page: +page,
limit: +limit,
total,
pages: Math.ceil(total / +limit)
}
});
};
(2) 商品詳細API
exports.getProduct = async (req, res) => {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({ success: false, error: '無効なID形式です' });
}
const product = await Product.findById(id);
if (!product || !product.isActive) {
return res.status(404).json({ success: false, error: '商品が見つかりません' });
}
res.json({ success: true, data: product });
};
(3) 商品作成API
exports.createProduct = async (req, res) => {
const { sku, title, price, category, description, stock } = req.body;
// 重複チェック
const existing = await Product.findOne({ sku });
if (existing) {
return res.status(409).json({
success: false,
error: `SKU ${sku}は既に存在します`
});
}
const product = await Product.create({
sku,
title,
price,
category,
description,
stock
});
res.status(201).json({ success: true, data: product });
};
(4) 商品更新API
exports.updateProduct = async (req, res) => {
const { id } = req.params;
const updates = req.body;
// 更新禁止フィールドを除外
delete updates._id;
delete updates.createdAt;
delete updates.__v;
const product = await Product.findByIdAndUpdate(
id,
{ $set: updates },
{
new: true, // 更新後のドキュメントを返す
runValidators: true // スキーマ検証を実行
}
);
if (!product) {
return res.status(404).json({ success: false, error: '商品が見つかりません' });
}
res.json({ success: true, data: product });
};
(5) 商品削除API(ソフト削除)
exports.deleteProduct = async (req, res) => {
const { id } = req.params;
// ソフト削除(isDeletedフラグを設定)
const product = await Product.findByIdAndUpdate(
id,
{ $set: { isDeleted: true } },
{ new: true }
);
if (!product) {
return res.status(404).json({ success: false, error: '商品が見つかりません' });
}
res.status(204).send(); // 204 No Content
};
4. 入力バリデーション
概念説明: 入力バリデーションはAPIセキュリティの第一防衛線です。無効データがアプリケーションに入るのを防ぎ、データ整合性を保証し、セキュリティ脆弱性(SQLインジェクション、NoSQLインジェクションなど)を防止します。JoiはNode.jsで最も広く使用されているバリデーションライブラリです。
バリデーションのベストプラクティス:
| レイヤー | 責務 | ツール |
|---|---|---|
| ルート層 | リクエスト形式チェック | Joi / express-validator |
| コントローラー層 | ビジネスルールチェック | カスタムロジック |
| モデル層 | データ整合性チェック | Mongooseスキーマ |
// validators/schemas.js
const Joi = require('joi');
// 商品作成・更新スキーマ
const productSchema = Joi.object({
sku: Joi.string()
.alphanum()
.min(3)
.max(20)
.required()
.messages({
'string.base': 'SKUは文字列である必要があります',
'string.min': 'SKUは{#limit}文字以上である必要があります',
'string.max': 'SKUは{#limit}文字以下である必要があります',
'any.required': 'SKUは必須です'
}),
title: Joi.string()
.min(2)
.max(200)
.required(),
price: Joi.number()
.positive()
.precision(2)
.required(),
category: Joi.string()
.valid('Electronics', 'Books', 'Clothing', 'Home')
.required(),
description: Joi.string()
.max(5000)
.optional(),
stock: Joi.number()
.integer()
.min(0)
.default(0)
});
// ユーザー登録スキーマ
const registerSchema = Joi.object({
email: Joi.string()
.email()
.required(),
username: Joi.string()
.alphanum()
.min(3)
.max(30)
.required(),
password: Joi.string()
.min(8)
.max(100)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.messages({
'string.pattern.base': 'パスワードは大文字、小文字、数字を含む必要があります'
})
});
module.exports = { productSchema, registerSchema };
// middlewares/validate.js
const { productSchema } = require('../validators/schemas');
exports.validateProduct = (req, res, next) => {
const { error, value } = productSchema.validate(req.body, {
abortEarly: false, // 全エラーを返す
stripUnknown: true // 不明フィールドを削除
});
if (error) {
const messages = error.details.map(d => d.message);
return res.status(400).json({
success: false,
error: '入力値が無効です',
details: messages
});
}
req.body = value; // 検証済み値に置換
next();
};
5. ページネーション、検索、フィルタリング
概念説明: 大規模データセットのAPIでは、ページネーションは不可欠です。全データを一度に返すと、ネットワーク帯域を浪費し、クライアントのメモリを圧迫し、レスポンス時間が長くなります。ページネーションには「オフセットベース」と「カーソルベース」の2つのアプローチがあります。
ページネーション方式の比較:
| 方式 | 実装 | 利点 | 欠点 | 適用 |
|---|---|---|---|---|
| オフセットベース | skip((page-1) * limit) |
ページ番号でジャンプ可能 | データ量増加で遅くなる | 一般的なWebアプリ |
| カーソルベース | find({_id: {$gt: lastId}}) |
高速、データ量に依存しない | ページ番号でジャンプ不可 | 無限スクロール |
オフセットベースページネーション:
// GET /api/products?page=2&limit=20
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 20, 100); // 最大100件
const products = await Product.find({ isActive: true })
.skip((page - 1) * limit)
.limit(limit)
.lean();
const total = await Product.countDocuments({ isActive: true });
res.json({
success: true,
data: products,
meta: {
page,
limit,
total,
pages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1
}
});
検索とフィルタリング:
// GET /api/products?category=Electronics&minPrice=100&maxPrice=500&search=phone
const buildQuery = (filters) => {
const query = { isActive: true };
// カテゴリフィルタ
if (filters.category) {
query.category = filters.category;
}
// 価格範囲フィルタ
if (filters.minPrice || filters.maxPrice) {
query.price = {};
if (filters.minPrice) query.price.$gte = +filters.minPrice;
if (filters.maxPrice) query.price.$lte = +filters.maxPrice;
}
// キーワード検索(インデックス使用)
if (filters.search) {
query.$text = { $search: filters.search };
}
return query;
};
const query = buildQuery(req.query);
const products = await Product.find(query)
.sort({ score: { $meta: 'textScore' } }) // テキスト検索スコアでソート
.lean();
6. 権限制御
概念説明: 権限制御はAPIセキュリティの中核です。RBAC(Role-Based Access Control)は「ユーザー → ロール → 権限」のモデルで、柔軟で管理しやすい権限システムを構築できます。各APIエンドポイントに権限要件を定義し、ミドルウェアで検証します。
RBACモデル:
graph LR
User[ユーザー] -->|持つ| Role[ロール]
Role -->|持つ| Permission[権限]
Permission -->|許可| Resource[リソース<br/>/api/products]
subgraph "ロール定義"
R1[customer] --> P1[レビュー作成]
R2[moderator] --> P2[レビュー管理]
R3[admin] --> P3[商品管理]
end
style User fill:#cce5ff
style Role fill:#fff3cd
style Permission fill:#d4edda
| ロール | 権限 | アクセス範囲 |
|---|---|---|
customer |
商品閲覧、レビュー作成 | /products, /reviews(自分) |
moderator |
レビュー管理 | /reviews(全て) |
admin |
商品・ユーザー管理 | /products, /users, /reviews(全て) |
// middlewares/auth.js
const jwt = require('jsonwebtoken');
// JWT認証ミドルウェア
exports.authenticate = (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: '認証が必要です' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // { id, role, username }
next();
} catch (err) {
return res.status(401).json({ error: '無効なトークンです' });
}
};
// 権限認可ミドルウェア
exports.authorize = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: '認証が必要です' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'この操作を行う権限がありません' });
}
next();
};
};
// リソース所有者チェック
exports.checkOwnership = (resourceType) => {
return async (req, res, next) => {
const resourceId = req.params.id;
const userId = req.user.id;
let resource;
if (resourceType === 'review') {
resource = await Review.findById(resourceId);
}
if (!resource || resource.userId.toString() !== userId) {
return res.status(403).json({ error: 'このリソースへのアクセス権がありません' });
}
next();
};
};
ルーティングでの使用例:
// routes/products.js
const { authenticate, authorize } = require('../middlewares/auth');
// 一般ユーザー:閲覧のみ
router.get('/', productController.listProducts);
router.get('/:id', productController.getProduct);
// 管理者:作成・更新・削除
router.post('/',
authenticate,
authorize('admin'),
productController.createProduct
);
router.put('/:id',
authenticate,
authorize('admin'),
productController.updateProduct
);
router.delete('/:id',
authenticate,
authorize('admin'),
productController.deleteProduct
);
7. APIテスト
概念概要: APIテストは品質保証の重要な部分です。Postman、curl、またはテストスクリプトでAPIをテストし、機能が正しく動作することを確認します。テストは正常系(成功ケース)と異常系(エラーケース)の両方をカバーすべきです。
curlテストコマンド集:
# === 商品一覧取得 ===
curl http://localhost:3000/api/v1/products
# === ページネーション・フィルタ付き ===
curl 'http://localhost:3000/api/v1/products?page=1&limit=10&category=Electronics&minPrice=100'
# === 商品詳細取得 ===
curl http://localhost:3000/api/v1/products/647f1f77bcf86cd799439001
# === 商品作成(管理者のみ)===
curl -X POST http://localhost:3000/api/v1/products \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"sku": "PHONE-001",
"title": "Smartphone X",
"price": 599,
"category": "Electronics",
"stock": 50
}'
# === 商品更新 ===
curl -X PUT http://localhost:3000/api/v1/products/647f1f77bcf86cd799439001 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"price": 549, "stock": 45}'
# === 商品削除 ===
curl -X DELETE http://localhost:3000/api/v1/products/647f1f77bcf86cd799439001 \
-H "Authorization: Bearer <token>"
▶ サンプル:完全な商品CRUD API + ページネーション + バリデーションのハンズオンガイド
// === 1. モデル定義(models/Product.js)===
const mongoose = require('mongoose');
const ProductSchema = new mongoose.Schema({
sku: { type: String, required: true, unique: true },
title: { type: String, required: true, maxlength: 200 },
description: { type: String, maxlength: 5000 },
price: { type: Number, required: true, min: 0 },
category: {
type: String,
enum: ['Electronics', 'Books', 'Clothing', 'Home'],
required: true
},
stock: { type: Number, default: 0, min: 0 },
rating: { type: Number, default: 0, min: 0, max: 5 },
reviewCount: { type: Number, default: 0 },
isActive: { type: Boolean, default: true },
isDeleted: { type: Boolean, default: false }
}, { timestamps: true });
// インデックス
ProductSchema.index({ category: 1, price: -1 });
ProductSchema.index({ title: 'text', description: 'text' });
// ソフト削除フィルタ
ProductSchema.pre(/^find/, function(next) {
this.where({ isDeleted: { $ne: true } });
next();
});
module.exports = mongoose.model('Product', ProductSchema);
// === 2. バリデーションスキーマ(validators/schemas.js)===
const Joi = require('joi');
const productSchema = Joi.object({
sku: Joi.string().alphanum().min(3).max(20).required(),
title: Joi.string().min(2).max(200).required(),
price: Joi.number().positive().precision(2).required(),
category: Joi.string().valid('Electronics', 'Books', 'Clothing', 'Home').required(),
description: Joi.string().max(5000).optional(),
stock: Joi.number().integer().min(0).default(0)
});
module.exports = { productSchema };
// === 3. コントローラー(controllers/productController.js)===
const Product = require('../models/Product');
const mongoose = require('mongoose');
exports.listProducts = async (req, res) => {
const { page = 1, limit = 20, category, search, sort = 'createdAt', order = 'desc' } = req.query;
const query = { isActive: true };
if (category) query.category = category;
if (search) query.$text = { $search: search };
const sortOption = {};
sortOption[sort] = order === 'desc' ? -1 : 1;
const [products, total] = await Promise.all([
Product.find(query)
.select('sku title price thumbnail rating category')
.sort(sortOption)
.limit(+limit)
.skip((+page - 1) * +limit)
.lean(),
Product.countDocuments(query)
]);
res.json({
success: true,
data: products,
meta: { page: +page, limit: +limit, total, pages: Math.ceil(total / +limit) }
});
};
exports.createProduct = async (req, res) => {
const product = await Product.create(req.body);
res.status(201).json({ success: true, data: product });
};
// === 4. ルーティング(routes/products.js)===
const express = require('express');
const router = express.Router();
const productController = require('../controllers/productController');
const { authenticate, authorize } = require('../middlewares/auth');
const { validateProduct } = require('../middlewares/validate');
router.get('/', productController.listProducts);
router.get('/:id', productController.getProduct);
router.post('/',
authenticate,
authorize('admin'),
validateProduct,
productController.createProduct
);
module.exports = router;
// === 5. テスト ===
// 商品一覧
curl http://localhost:3000/api/v1/products?category=Electronics&page=1&limit=10
// 商品作成
curl -X POST http://localhost:3000/api/v1/products \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{"sku":"PHONE-001","title":"Smartphone X","price":599,"category":"Electronics","stock":50}'
出力:
TEXT 📖 参照専用RESTful APIが商品一覧(ページネーション・フィルタ付き)と商品作成(管理者のみ・バリデーション付き)を提供。
▶ サンプル 2:Joiバリデーションの実装(難易度 ⭐⭐)
// validators/schemas.js - Joiバリデーションスキーマ
const Joi = require('joi');
// 商品バリデーションスキーマ
const productSchema = Joi.object({
sku: Joi.string()
.alphanum()
.min(3)
.max(20)
.required()
.messages({
'string.base': 'SKUは文字列である必要があります',
'string.min': 'SKUは{#limit}文字以上である必要があります',
'any.required': 'SKUは必須です'
}),
title: Joi.string()
.min(2)
.max(200)
.required()
.messages({
'string.min': 'タイトルは{#limit}文字以上必要です'
}),
price: Joi.number()
.positive()
.precision(2)
.required(),
category: Joi.string()
.valid('Electronics', 'Books', 'Clothing', 'Home', 'Sports')
.required(),
description: Joi.string()
.max(5000)
.optional()
.allow(''),
stock: Joi.number()
.integer()
.min(0)
.default(0)
});
// ユーザー登録バリデーションスキーマ
const registerSchema = Joi.object({
email: Joi.string()
.email()
.required()
.messages({
'string.email': '有効なメールアドレスを入力してください'
}),
username: Joi.string()
.alphanum()
.min(3)
.max(30)
.required(),
password: Joi.string()
.min(8)
.max(100)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.messages({
'string.pattern.base': 'パスワードは大文字、小文字、数字を含む必要があります',
'string.min': 'パスワードは{#limit}文字以上必要です'
})
});
module.exports = { productSchema, registerSchema };
// middlewares/validate.js - バリデーションミドルウェア
const { productSchema } = require('../validators/schemas');
exports.validateProduct = (req, res, next) => {
const { error, value } = productSchema.validate(req.body, {
abortEarly: false, // 全エラーを返す
stripUnknown: true, // 不明フィールドを削除
convert: true // 型変換を許可
});
if (error) {
const messages = error.details.map(d => d.message);
return res.status(400).json({
success: false,
error: '入力値が無効です',
details: messages
});
}
req.body = value; // 検証済み値に置換
next();
};
// テスト
const { error } = productSchema.validate({
sku: 'AB',
price: -10,
category: 'Invalid'
});
// error.details = [
// { message: 'SKUは3文字以上である必要があります' },
// { message: '"price"は正の数値である必要があります' },
// { message: '"category"はElectronics, Books, ...のいずれかである必要があります' }
// ]
出力:
TEXT 📖 参照専用Joiバリデーション:SKU/タイトル/価格/カテゴリの形式チェック。abortEarly: falseで全エラーを一度に返し、ユーザーフレンドリーなエラーメッセージを表示。
▶ サンプル 3:RBAC権限制御の実装(難易度 ⭐⭐⭐)
// middlewares/auth.js - RBAC権限制御
const jwt = require('jsonwebtoken');
// JWT認証ミドルウェア
exports.authenticate = (req, res, next) => {
const authHeader = req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, error: '認証が必要です' });
}
const token = authHeader.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // { id, role, username }
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ success: false, error: 'トークンの有効期限が切れています' });
}
return res.status(401).json({ success: false, error: '無効なトークンです' });
}
};
// ロールベース権限チェック
exports.authorize = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ success: false, error: '認証が必要です' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
error: 'この操作を行う権限がありません',
required: roles,
current: req.user.role
});
}
next();
};
};
// リソース所有者チェック(レビューなど)
exports.checkOwnership = (resourceType) => {
return async (req, res, next) => {
const resourceId = req.params.id;
const userId = req.user.id;
try {
let resource;
if (resourceType === 'review') {
const Review = require('../models/Review');
resource = await Review.findById(resourceId);
} else if (resourceType === 'order') {
const Order = require('../models/Order');
resource = await Order.findById(resourceId);
}
if (!resource) {
return res.status(404).json({ success: false, error: 'リソースが見つかりません' });
}
// 管理者は常にアクセス可能
if (req.user.role === 'admin') {
return next();
}
// 所有者チェック
if (resource.userId.toString() !== userId) {
return res.status(403).json({ success: false, error: 'このリソースへのアクセス権がありません' });
}
next();
} catch (error) {
next(error);
}
};
};
// routes/reviews.js - レビュールーティングでの使用例
const express = require('express');
const router = express.Router();
const reviewController = require('../controllers/reviewController');
const { authenticate, authorize, checkOwnership } = require('../middlewares/auth');
// 公開ルート
router.get('/product/:productId', reviewController.listByProduct);
// 認証必要ルート
router.post('/',
authenticate,
authorize('customer', 'admin'),
reviewController.createReview
);
// 所有者または管理者のみ更新可能
router.put('/:id',
authenticate,
checkOwnership('review'),
reviewController.updateReview
);
// 所有者または管理者のみ削除可能
router.delete('/:id',
authenticate,
checkOwnership('review'),
reviewController.deleteReview
);
module.exports = router;
出力:
TEXT 📖 参照専用RBAC権限制御:authenticateでJWT検証、authorizeでロールチェック、checkOwnershipでリソース所有者チェック。レビューAPIはcustomer/adminが作成可能、更新・削除は所有者または管理者のみ。
❓ よくある質問
📖 まとめ
- RESTful API設計:リソース中心 + HTTPメソッド
- CRUD操作:一覧・詳細・作成・更新・削除
- 入力バリデーション:Joiスキーマ + エラーメッセージ
- ページネーション:オフセットベース + メタデータ
- 権限制御:RBAC + ミドルウェア
📝 練習問題
- 基礎問題(⭐): 商品一覧APIを実装(ページネーション付き)。
- 基礎問題(⭐): Joiで商品作成バリデーションスキーマを定義。
- 応用問題(⭐⭐): 商品CRUD APIを完全に実装(5つの操作)。
- 応用問題(⭐⭐): RBAC権限ミドルウェアを実装(customer/adminロール)。
- チャレンジ(⭐⭐⭐): 完全なRESTful APIを構築(検索 + フィルタ + ページネーション + バリデーション + 権限制御)。