「実践:RESTful API プロジェクト」
1. プロジェクトの背景
アリスとボブには、オンライン書店向けの書籍管理APIを開発するという課題が与えられた。アリスはルーティングとミドルウェアのアーキテクチャの設計を担当し、ボブはビジネスロジックの記述を担当した。二人は標準化されたレスポンス形式とエラー処理の規約について合意し、プロジェクトのセットアップから完全なCRUD機能の実装に至るまで、わずか2時間でプロジェクト全体を完了させた。
(1) 以下のことを学びます
- Express、ルーティング、ミドルウェアを組み合わせて、完全なAPIを構築する
- プロジェクトのディレクトリ構造の設計(routes / controllers / middleware / models)
- インメモリデータストレージと完全なCRUD操作の実装
- リクエストの検証と統一的なエラー処理
- APIテスト手法(curl / Postman)
- 統一された応答形式の設計
2. プロジェクトのディレクトリ構造の設計
適切に構成されたディレクトリ階層は、プロジェクトの保守性の基盤となります。コミュニティのベストプラクティスに従い、Aliceは責任範囲に基づいてプロジェクトを階層化しています:
TEXT
book-api/
├── app.js
├── routes/
│ └── books.js
├── controllers/
│ └── bookController.js
├── middleware/
│ ├── validate.js
│ └── errorHandler.js
└── models/
└── bookModel.js
| ディレクトリ/ファイル | 担当 | 説明 |
|---|---|---|
app.js |
エントリファイル | Expressのインスタンスを作成し、ルーティングとミドルウェアを紐付ける |
routes/ |
ルーティング定義 | 対応するコントローラーを指すHTTPメソッドとパスを定義します |
controllers/ |
ビジネスロジック | リクエストを処理し、モデルを呼び出し、レスポンスを返す |
middleware/ |
ミドルウェア | リクエストの検証やエラー処理などの横断的関心事 |
models/ |
データモデル | データ格納およびデータ操作のカプセル化 |
3. APIエンドポイントの設計
業務要件に基づき、ボブはすべてのAPIエンドポイントの一覧を作成しました:
| HTTPメソッド | パス | 関数 | 成功時のステータスコード | 失敗時のステータスコード |
|---|---|---|---|---|
| 取得 | /api/books |
すべての書籍を取得 | 200 | — |
| GET | /api/books/:id |
1冊の書籍を取得 | 200 | 404 |
| 投稿 | /api/books |
新刊 | 201 | 400 |
| PUT | /api/books/:id |
書籍を更新 | 200 | 404 / 400 |
| 削除 | /api/books/:id |
書籍を削除 | 200 | 404 |
4. 回答の標準形式
アリスは、フロントエンドチームがフィールド名を推測する必要がないよう、一貫したレスポンス形式を採用するよう主張しています:
▶ 例:成功した応答
JAVASCRIPT
{
"success": true,
"data": { "id": 1, "title": "Node.js Guide", "author": "Alice" }
}
▶ 例:エラー応答
JAVASCRIPT
{
"success": false,
"error": { "code": 404, "message": "Book not found" }
}
| フィールド | タイプ | 説明 |
|---|---|---|
success |
ブール値 | リクエストが成功したかどうか |
data |
任意 | 成功時に返されるデータ |
error.code |
番号 | エラーステータスコード |
error.message |
文字列 | エラーの説明 |
5. データモデル層
Bobは、modelsディレクトリ内の配列を使用してデータベースをシミュレートし、すべてのデータ操作をカプセル化しています:
▶ 例:bookModel.js
JAVASCRIPT
const books = [
{ id: 1, title: "Node.js Guide", author: "Alice", year: 2024 }
];
let nextId = 2;
function findAll() {
return books;
}
function findById(id) {
return books.find(b => b.id === id);
}
function create(data) {
const book = { id: nextId++, ...data };
books.push(book);
return book;
}
function update(id, data) {
const index = books.findIndex(b => b.id === id);
if (index === -1) return null;
books[index] = { ...books[index], ...data };
return books[index];
}
function remove(id) {
const index = books.findIndex(b => b.id === id);
if (index === -1) return false;
books.splice(index, 1);
return true;
}
module.exports = { findAll, findById, create, update, remove };
6. リクエスト検証ミドルウェア
Aliceは、無効なリクエストがコントローラーに到達しないようにするため、ミドルウェア層でデータ検証を実装しています:
▶ 例:validate.js
JAVASCRIPT
function validateBook(req, res, next) {
const { title, author, year } = req.body;
const errors = [];
if (!title || typeof title !== "string") {
errors.push("title is required and must be a string");
}
if (!author || typeof author !== "string") {
errors.push("author is required and must be a string");
}
if (year !== undefined && (typeof year !== "number" || year < 0)) {
errors.push("year must be a non-negative number");
}
if (errors.length > 0) {
return res.status(400).json({
success: false,
error: { code: 400, message: errors.join("; ") }
});
}
next();
}
module.exports = { validateBook };
7. エラー処理ミドルウェア
▶ 例:errorHandler.js
JAVASCRIPT
function errorHandler(err, req, res, next) {
console.error(err.stack);
const status = err.status || 500;
res.status(status).json({
success: false,
error: { code: status, message: err.message || "Internal Server Error" }
});
}
function createError(status, message) {
const err = new Error(message);
err.status = status;
return err;
}
module.exports = { errorHandler, createError };
8. コントローラ層
Bobはコントローラー内でビジネスロジックを処理し、モデルを呼び出し、標準化された形式でレスポンスを返します:
▶ 例:bookController.js
JAVASCRIPT
const Book = require("../models/bookModel");
const { createError } = require("../middleware/errorHandler");
function getAllBooks(req, res) {
res.json({ success: true, data: Book.findAll() });
}
function getBookById(req, res, next) {
const book = Book.findById(Number(req.params.id));
if (!book) return next(createError(404, "Book not found"));
res.json({ success: true, data: book });
}
function createBook(req, res) {
const book = Book.create(req.body);
res.status(201).json({ success: true, data: book });
}
function updateBook(req, res, next) {
const book = Book.update(Number(req.params.id), req.body);
if (!book) return next(createError(404, "Book not found"));
res.json({ success: true, data: book });
}
function deleteBook(req, res, next) {
const removed = Book.remove(Number(req.params.id));
if (!removed) return next(createError(404, "Book not found"));
res.json({ success: true, data: { message: "Book deleted" } });
}
module.exports = { getAllBooks, getBookById, createBook, updateBook, deleteBook };
9. ルートの定義
Aliceはルーティングをコントローラから切り離しています。ルーティングはマッピングのみを担当します:
▶ 例:routes/books.js
JAVASCRIPT
const express = require("express");
const router = express.Router();
const controller = require("../controllers/bookController");
const { validateBook } = require("../middleware/validate");
router.get("/", controller.getAllBooks);
router.get("/:id", controller.getBookById);
router.post("/", validateBook, controller.createBook);
router.put("/:id", validateBook, controller.updateBook);
router.delete("/:id", controller.deleteBook);
module.exports = router;
10. エントリーファイル app.js
▶ 例:app.js
JAVASCRIPT
const express = require("express");
const booksRoute = require("./routes/books");
const { errorHandler } = require("./middleware/errorHandler");
const app = express();
app.use(express.json());
app.use("/api/books", booksRoute);
app.use(errorHandler);
const PORT = 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
11. プロジェクトアーキテクチャの概要
アリスは、リクエストの流れを明確に示したアーキテクチャ図を描きました:
graph TD
A[app.js Entrance] --> B[routes/books.js]
B --> C{Needs verification?}
C -->|POST/PUT| D[middleware/validate.js]
C -->|GET/DELETE| E[controllers/bookController.js]
D --> E
E --> F[models/bookModel.js]
F --> E
E --> G[Standardized Response Format]
G --> H[Client]
E -->|Exception| I[middleware/errorHandler.js]
I --> H
12. APIテスト
ボブは curl を使用して、各エンドポイントを1つずつ検証します:
| curl コマンド | 説明 |
|---|---|
curl localhost:3000/api/books |
すべての書籍を表示 |
curl localhost:3000/api/books/1 |
特定の書籍を探す |
curl -X POST -H "Content-Type: application/json" -d '{"title":"New Book","author":"Bob","year":2025}' localhost:3000/api/books |
新刊 |
curl -X PUT -H "Content-Type: application/json" -d '{"year":2026}' localhost:3000/api/books/1 |
書籍の更新 |
curl -X DELETE localhost:3000/api/books/1 |
書籍を削除 |
▶ 例:挿入およびクエリ操作のテスト
BASH
# Add a book
curl -X POST -H "Content-Type: application/json" \
-d '{"title":"Express in Action","author":"Evan","year":2024}' \
localhost:3000/api/books
TEXT
{"success":true,"data":{"id":2,"title":"Express in Action","author":"Evan","year":2024}}
BASH
# Search All Books
curl localhost:3000/api/books
TEXT
{"success":true,"data":[{"id":1,"title":"Node.js Guide","author":"Alice","year":2024},{"id":2,"title":"Express in Action","author":"Evan","year":2024}]}
13. 包括的な例:書籍管理APIの完全版
これまでのすべてのモジュールを組み合わせた、プロジェクト全体のコアとなるコードフローは以下の通りです:
JAVASCRIPT
// models/bookModel.js
const books = [{ id: 1, title: "Node.js Guide", author: "Alice", year: 2024 }];
let nextId = 2;
function findAll() { return books; }
function findById(id) { return books.find(b => b.id === id); }
function create(data) {
const book = { id: nextId++, ...data };
books.push(book);
return book;
}
function update(id, data) {
const index = books.findIndex(b => b.id === id);
if (index === -1) return null;
books[index] = { ...books[index], ...data };
return books[index];
}
function remove(id) {
const index = books.findIndex(b => b.id === id);
if (index === -1) return false;
books.splice(index, 1);
return true;
}
module.exports = { findAll, findById, create, update, remove };
JAVASCRIPT
// middleware/validate.js
function validateBook(req, res, next) {
const { title, author } = req.body;
const errors = [];
if (!title || typeof title !== "string") errors.push("title is required");
if (!author || typeof author !== "string") errors.push("author is required");
if (errors.length > 0) {
return res.status(400).json({
success: false,
error: { code: 400, message: errors.join("; ") }
});
}
next();
}
module.exports = { validateBook };
JAVASCRIPT
// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
const status = err.status || 500;
res.status(status).json({
success: false,
error: { code: status, message: err.message || "Internal Server Error" }
});
}
function createError(status, message) {
const err = new Error(message);
err.status = status;
return err;
}
module.exports = { errorHandler, createError };
JAVASCRIPT
// controllers/bookController.js
const Book = require("../models/bookModel");
const { createError } = require("../middleware/errorHandler");
function getAllBooks(req, res) {
res.json({ success: true, data: Book.findAll() });
}
function getBookById(req, res, next) {
const book = Book.findById(Number(req.params.id));
if (!book) return next(createError(404, "Book not found"));
res.json({ success: true, data: book });
}
function createBook(req, res) {
const book = Book.create(req.body);
res.status(201).json({ success: true, data: book });
}
function updateBook(req, res, next) {
const book = Book.update(Number(req.params.id), req.body);
if (!book) return next(createError(404, "Book not found"));
res.json({ success: true, data: book });
}
function deleteBook(req, res, next) {
const removed = Book.remove(Number(req.params.id));
if (!removed) return next(createError(404, "Book not found"));
res.json({ success: true, data: { message: "Book deleted" } });
}
module.exports = { getAllBooks, getBookById, createBook, updateBook, deleteBook };
JAVASCRIPT
// routes/books.js
const express = require("express");
const router = express.Router();
const ctrl = require("../controllers/bookController");
const { validateBook } = require("../middleware/validate");
router.get("/", ctrl.getAllBooks);
router.get("/:id", ctrl.getBookById);
router.post("/", validateBook, ctrl.createBook);
router.put("/:id", validateBook, ctrl.updateBook);
router.delete("/:id", ctrl.deleteBook);
module.exports = router;
JAVASCRIPT
// app.js
const express = require("express");
const booksRoute = require("./routes/books");
const { errorHandler } = require("./middleware/errorHandler");
const app = express();
app.use(express.json());
app.use("/api/books", booksRoute);
app.use(errorHandler);
app.listen(3000, () => console.log("Server running on port 3000"));
BASH
# Start and Test
node app.js
curl -X POST -H "Content-Type: application/json" \
-d '{"title":"Clean Code","author":"Robert","year":2008}' \
localhost:3000/api/books
curl localhost:3000/api/books/1
curl -X DELETE localhost:3000/api/books/1
❓ よくある質問
Q なぜコントローラーとルートは分離されているのですか?
A 関心の分離のためです。ルートはパスのマッピングを定義し、コントローラーはビジネスロジックに焦点を当てています。これらを分離することで、それぞれを独立して変更・テストできるようになります。
Q API をテストするにはどうすればよいですか?
A コマンドラインでは
curl を、グラフィカルインターフェースでは Postman または VS Code 用の Thunder Client プラグインを、自動テストでは supertest ライブラリを使用してください。Q データ検証はどのレイヤーで行うべきですか?
A リクエストがコントローラーに到達する前に無効なデータを遮断し、コントローラーのロジックをクリーンな状態に保つため、ミドルウェア層で行うべきです。
Q 見つからないリソースはどのように扱うべきですか?
A 標準化されたエラー形式
{ success: false, error: { code: 404, message: "Book not found" } } とともに、404 ステータスコードを返します。Q 自分のプロジェクトでTypeScriptを使うべきですか?
A 小規模な練習用プロジェクトであればJavaScriptで十分ですが、大規模な本番環境向けのプロジェクトでは、型安全性やIDEのサポートが充実していることから、TypeScriptの使用をお勧めします。
Q 再起動後、メモリに保存されたデータは失われますか?
A はい。配列はプロセスメモリに保存されるため、サービスが再起動するとデータは消去されます。本番環境では、データベース(MongoDB、PostgreSQLなど)を使用することをお勧めします。
Q PUTとPATCHの違いは何ですか?
A PUTではリソース全体を指定して完全に置き換える必要がありますが、PATCHでは変更が必要なフィールドのみを送信して部分的な更新を行います。このレッスンでは、簡便さを考慮してPUTを使用します。
📖 まとめ
- プロジェクトの背景:基本概念と活用方法
- プロジェクトディレクトリ構造設計の基本概念と活用方法
- APIエンドポイント設計の基本概念と活用方法
- 統一応答フォーマットの基本概念と使用方法
- データモデル層の基本概念と使用方法
- リクエスト検証ミドルウェアの基本概念と使用方法
- エラー処理ミドルウェアの基本概念と使用方法
- コントローラ層の基本概念と使用方法
📝 練習問題
- このレッスンにあるすべてのコード例を完成させ、それぞれが正しく動作することを確認してください。
- 包括的な例を修正し、独自の拡張機能を追加する
- 公式ドキュメントを確認し、このレッスンで扱われていないAPIを1~2つ見つけ、それらのテストコードを作成してください。
- 振り返り:このレッスンで学んだことを、実際のプロジェクトにどのように活かしますか?
- このレッスンで学んだことと、これまでのレッスンの内容を組み合わせて、小さなプロジェクトを作成してみてください。



