REST APIの設計:コラボレーションへの道――混沌から明快さへ
アリスのチームは、タスク管理システムのフロントエンドとバックエンドの両方を開発しています。フロントエンド開発者たちは、どのAPIが利用可能か、どのHTTPメソッドを使用すべきか、またレスポンスがどのような形式になるのかが分からないと不満を漏らしています。バックエンド開発者たちも同様に不満を抱えています。同じ「タスクの更新」操作であっても、POSTを使う者もいれば、PUTを使う者、PATCHを使う者もおり、その結果、レスポンスの形式がまちまちになっています。チーム間の連携は混乱状態に陥っています。
アリスはREST仕様を採用することに決めました。チームがリソースの命名規則、メソッドのマッピング、ステータスコード、およびレスポンス形式を標準化した結果、APIは明確で予測可能なものになりました。これにより、フロントエンドチームはAPIドキュメントを繰り返し確認する必要がなくなり、連携の効率は2倍になりました。
1. 学習内容
- RESTアーキテクチャの4つの基本原則
- CRUD操作とHTTPメソッドの適切な対応付け
- RESTful URL 設計の「すべきこと」と「すべきでないこと」
- HTTPステータスコードの選定戦略
- リクエストおよびレスポンスに関するJSON仕様
- APIのバージョン管理に関する3つの戦略
- REST成熟度モデルとHATEOAS
2. RESTアーキテクチャの原則
(1) RESTとは何ですか?
REST(Representational State Transfer)は、2000年にロイ・フィールディングによって提唱されたソフトウェアアーキテクチャのスタイルである。これは、Webアプリケーションのインターフェースを設計するための一連の制約を定義している。RESTはプロトコルや標準ではなく、あくまで設計哲学である。
(2) 4つの基本原則
| 原則 | 意味 | 例 |
|---|---|---|
| リソース | すべてはリソースであり、URLによって識別される | /tasks, /users/42 |
| 表現層 | リソースが表現される形式(JSONなど) | {"id": 1, "title": "Learn REST"} |
| ステートレス | 各リクエストには必要な情報がすべて含まれている | リクエストにはトークンが含まれており、セッションに依存しない |
| 一元化されたインターフェース | 標準的なHTTPメソッドを使用してリソースを操作 | GETで読み取り、POSTで作成、DELETEで削除 |
▶ 例:ステートレスとステートフル
// Stateful: Depends on server session (not RESTful)
app.post('/login', (req, res) => {
req.session.userId = 42; // Server State Saving
res.send('logged in');
});
app.get('/profile', (req, res) => {
const userId = req.session.userId; // Depends on the server status
res.json({ id: userId, name: 'Alice' });
});
// Stateless:Include authentication information with every request(RESTful)
app.get('/profile', (req, res) => {
const userId = verifyToken(req.headers.authorization);
res.json({ id: userId, name: 'Alice' });
});
3. CRUD と HTTP メソッドのマッピング
(1) 標準的なマッピング関係
RESTの核心となる考え方は、URLに行動を表す動詞を埋め込むのではなく、HTTPメソッドを用いてリソースに対する操作の意図を表現することにある。
| CRUD操作 | HTTPメソッド | パス | 冪等性 | セキュリティ |
|---|---|---|---|---|
| 作成 | 投稿 | /tasks |
いいえ | いいえ |
| 読み取り(一覧) | GET | /tasks |
はい | はい |
| 閲覧(単体) | GET | /tasks/42 |
はい | はい |
| 更新(全文) | PUT | /tasks/42 |
はい | いいえ |
| 更新(一部) | パッチ | /tasks/42 |
いいえ | いいえ |
| 削除 | 削除 | /tasks/42 |
はい | いいえ |
(2) 冪等性に関する詳細な説明
冪等性とは、同じリクエストを1回実行しても、複数回実行しても同じ結果が得られることを指します。GET、PUT、DELETEは冪等ですが、POSTやPATCHは冪等ではありません。
▶ 例:PUTとPOSTの冪等性の違い
// POST: Creates a new resource on every call (Non-idempotent)
// 1st POST /tasks → Create id=1
// 2nd POST /tasks → Create id=2
app.post('/tasks', (req, res) => {
const task = { id: nextId++, ...req.body };
tasks.push(task);
res.status(201).json(task);
});
// PUT: Replaces the same resource on every call (Idempotent)
// 1st PUT /tasks/1 → Replace id=1
// 2nd PUT /tasks/1 → Replace id=1 (The results are the same)
app.put('/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) return res.status(404).json({ error: 'Not found' });
tasks[idx] = { id: parseInt(req.params.id), ...req.body };
res.json(tasks[idx]);
});
▶ 例:PATCH による部分更新
// PATCH:Modify only the fields provided
app.patch('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) return res.status(404).json({ error: 'Not found' });
Object.assign(task, req.body);
res.json(task);
});
// Request:Modify only status Field
// PATCH /tasks/1 {"status": "done"}
// Raw Data:{"id":1,"title":"Learn REST","status":"pending"}
// Results:{"id":1,"title":"Learn REST","status":"done"}
4. URLの設計ガイドライン
(1) 基本ルール
RESTfulなURL設計では、APIを直感的で読みやすいものにするための一連の規約に従います。
| ルール | 正解 ✅ | 誤り ❌ |
|---|---|---|
| 動詞ではなく名詞を使う | GET /tasks |
GET /getTasks |
| 単数形ではなく複数形を使う | /tasks |
/task |
| ネスト構造による関係の表現 | /users/42/tasks |
/tasksByUser?userId=42 |
| 最大3レベル | /users/42/tasks/1 |
/orgs/1/teams/2/users/42/tasks |
| クエリパラメータで絞り込む | /tasks?status=done |
/doneTasks |
| kebab-case の使用 | /task-items |
/taskItems |
(2) ネストされたリソースの設計
ネストされたリソースは、階層的な関係を示します。子リソースが親リソースから独立して存在できない場合は、ネストされたパスを使用してください。
▶ 例:タスク管理システムのURL設計
# Mission Resources
GET /tasks # Get the task list
POST /tasks # Create a New Task
GET /tasks/42 # Get a Single Task
PUT /tasks/42 # Full Update Task
PATCH /tasks/42 # Partial Update Task
DELETE /tasks/42 # Delete Task
# Comments on the Assignment(Nested Resources)
GET /tasks/42/comments # Get a Task42List of comments
POST /tasks/42/comments # For the mission42Add a comment
GET /tasks/42/comments/7 # Get a Task42Comments on7
DELETE /tasks/42/comments/7 # Delete Comment7
# Filtering and Pagination
GET /tasks?status=done&page=2&limit=20
GET /tasks?sort=-created_at # Sort by creation date in reverse chronological order
▶ 例:URLクエリパラメータの一般的な使い方
app.get('/tasks', (req, res) => {
let result = [...tasks];
// Filter
if (req.query.status) {
result = result.filter(t => t.status === req.query.status);
}
// Sort
if (req.query.sort) {
const field = req.query.sort.startsWith('-')
? req.query.sort.slice(1)
: req.query.sort;
const order = req.query.sort.startsWith('-') ? -1 : 1;
result.sort((a, b) => (a[field] > b[field] ? order : -order));
}
// Pagination
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const start = (page - 1) * limit;
result = result.slice(start, start + limit);
res.json({
data: result,
page,
limit,
total: tasks.length
});
});
5. HTTPステータスコードの選択
(1) ステータスコードの分類と選定
HTTPステータスコードは、REST APIとクライアント間の通信における重要なシグナルです。適切なステータスコードを選択することで、クライアントはリクエストの結果を正確に把握することができます。
| シナリオ | ステータスコード | 意味 | 説明 |
|---|---|---|---|
| リソースの取得に成功しました | 200 OK | リクエストが成功しました | GET/PUT/PATCH が成功した際に返されるステータス |
| リソースの作成に成功しました | 201 作成済み | リソースが作成されました | POSTが成功した際に返されるステータスコード。Locationヘッダーが含まれている必要があります |
| リソースの削除に成功しました | 204 コンテンツなし | コンテンツなし | DELETE が成功した際に返されるステータスコード。レスポンス本文はありません |
| 無効なリクエストパラメータ | 400 Bad Request | クライアントリクエストの構文エラー | 必須フィールドの欠落、形式が不正 |
| 認証されていません | 401 認証なし | 認証情報が提供されていません | トークンがありません、または無効です |
| 権限なし | 403 アクセス拒否 | 認証済みだが権限なし | 一般ユーザーが管理画面にアクセス |
| リソースが存在しません | 404 Not Found | 要求されたリソースは存在しません | この ID のリソースが見つかりませんでした |
| サーバーエラー | 500 内部サーバーエラー | 内部サーバーエラー | 未処理の例外 |
(2) よくある間違い:ステータスコードの誤用
▶ 例:ステータスコードの正しい使い方
// Create a Resource → 201 + Location
app.post('/tasks', (req, res) => {
const task = { id: nextId++, ...req.body };
tasks.push(task);
res.status(201)
.location(`/tasks/${task.id}`)
.json(task);
});
// Delete Resource → 204(Non-responsive body)
app.delete('/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) return res.status(404).json({ error: 'Not found' });
tasks.splice(idx, 1);
res.status(204).end();
});
// Verification Failed → 400 + Error Details
app.post('/tasks', (req, res) => {
if (!req.body.title) {
return res.status(400).json({
error: 'Validation failed',
details: [{ field: 'title', message: 'Title is required' }]
});
}
// ...
});
6. リクエストとレスポンスの形式
(1) JSON仕様の規約
| 規約 | 標準 | 例 |
|---|---|---|
| フィールド名 | camelCase | createdAt, taskId |
| 日付形式 | ISO 8601 | 2025-07-03T10:30:00Z |
| リストの応答 | データとページネーション情報を含む | {"data": [...], "total": 100} |
| エラー応答 | エラーと詳細を含む | {"error": "Not found", "details": [...]} |
| NULL値の扱い | フィールドを省略する代わりに null を使用する |
{"description": null} |
| ID タイプ | 文字列(精度の問題を避けるため) | {"id": "42"} |
▶ 例:標準化されたリスト形式の回答
app.get('/tasks', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const start = (page - 1) * limit;
const data = tasks.slice(start, start + limit);
res.json({
data,
pagination: {
page,
limit,
total: tasks.length,
totalPages: Math.ceil(tasks.length / limit)
}
});
});
// Response Example
{
"data": [
{
"id": "1",
"title": "Learn REST",
"status": "pending",
"createdAt": "2025-07-03T10:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"totalPages": 1
}
}
▶ 例:標準化されたエラー応答
// Unified Error Handling Middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error',
details: err.details || [],
requestId: req.id,
timestamp: new Date().toISOString()
});
});
// Custom Error Classes
class ApiError extends Error {
constructor(status, message, details = []) {
super(message);
this.status = status;
this.details = details;
}
}
// Usage
app.get('/tasks/:id', (req, res, next) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
return next(new ApiError(404, 'Task not found', [
{ field: 'id', message: `No task with id ${req.params.id}` }
]));
}
res.json(task);
});
// Examples of Error Responses
{
"error": "Task not found",
"details": [
{ "field": "id", "message": "No task with id 999" }
],
"requestId": "req-a1b2c3",
"timestamp": "2025-07-03T10:30:00Z"
}
7. APIのバージョン管理戦略
(1) 3つの主流戦略の比較
| 戦略 | 例 | メリット | デメリット | 適用可能なシナリオ |
|---|---|---|---|---|
| URLパス | /api/v1/tasks |
直感的で、ブラウザ上でテスト可能 | URLが長くなるため、RESTの純粋主義者の間では賛否が分かれる | ほとんどの公開API |
| リクエストヘッダー | Accept: application/vnd.myapi.v1+json |
すっきりとした純粋なRESTful URL | 直感的ではなく、デバッグが難しい | RESTの純粋性を追求 |
| クエリパラメータ | /api/tasks?version=1 |
最も単純 | 見落としやすい;複雑なキャッシュ戦略 | 内部API、単純なプロジェクト |
(2) バージョン管理のベストプラクティス
- v1からは、バージョン番号を省略しないでください
- 互換性を損なう変更がある場合にのみ、メジャーバージョンへアップグレードしてください
- 旧バージョンは、少なくとも6か月間はサポートされます
- レスポンスヘッダーに現在のバージョンを指定する
▶ 例:URLパスのバージョン管理の実装
// Routing Structure
// /api/v1/tasks → v1 Logic
// /api/v2/tasks → v2 Logic
const express = require('express');
const app = express();
// v1 Routing
const v1Router = express.Router();
v1Router.get('/tasks', (req, res) => {
res.json({ data: tasks, version: 'v1' }); // v1 Return Format
});
// v2 Routing(Response Format Upgrade)
const v2Router = express.Router();
v2Router.get('/tasks', (req, res) => {
res.json({ // v2 Return Format(Includes pagination)
data: tasks,
pagination: { page: 1, total: tasks.length }
});
});
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Response Header Version
app.use('/api/v2', (req, res, next) => {
res.setHeader('X-API-Version', '2.0');
next();
});
8. REST成熟度モデル
(1) リチャードソンの成熟度モデル
レナード・リチャードソンは、APIのRESTful成熟度を測定するためのモデルを提案した:
graph TD
L0["Level 0: Single endpoint<br/>HTTP as a tunnel<br/>e.g. POST /api {action: getTasks}"]
L1["Level 1: Resource URLs<br/>One URL per resource<br/>e.g. POST /tasks, POST /users"]
L2["Level 2: HTTP Methods<br/>GET/POST/PUT/DELETE<br/>e.g. GET /tasks, DELETE /tasks/1"]
L3["Level 3: HATEOAS<br/>Responses contain hyperlinks<br/>e.g. Response includes next, self links"]
L0 --> L1 --> L2 --> L3
style L0 fill:#ff6b6b,color:#fff
style L1 fill:#ffa502,color:#fff
style L2 fill:#2ed573,color:#fff
style L3 fill:#1e90ff,color:#fff
| レベル | 特徴 | サンプルリクエスト | サンプルレスポンス |
|---|---|---|---|
| レベル 0 | HTTP トンネル、単一 URL | POST /api {"action":"getTasks"} |
{"tasks": [...]} |
| レベル 1 | 資源の分別;方法は問わない | POST /tasks |
{"tasks": [...]} |
| レベル 2 | 意味的に正しい HTTP | GET /tasks |
200 {"data": [...]} |
| レベル 3 | HATEOAS ハイパーメディア | GET /tasks/1 |
_links ナビゲーションを含む |
(2) HATEOASの詳細な解説
HATEOAS(Hypermedia as the Engine of Application State)では、レスポンスに関連する操作へのリンクを含めることが求められているため、クライアントはURLをハードコーディングする必要がありません。
▶ 例:レベル 3 — HATEOAS リンクを含む応答
app.get('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) return res.status(404).json({ error: 'Not found' });
res.json({
...task,
_links: {
self: { href: `/tasks/${task.id}`, method: 'GET' },
update: { href: `/tasks/${task.id}`, method: 'PUT' },
delete: { href: `/tasks/${task.id}`, method: 'DELETE' },
assign: { href: `/tasks/${task.id}/assignee`, method: 'POST' },
comments: { href: `/tasks/${task.id}/comments`, method: 'GET' }
}
});
});
// Response
{
"id": "1",
"title": "Learn REST",
"status": "pending",
"createdAt": "2025-07-03T10:30:00Z",
"_links": {
"self": { "href": "/tasks/1", "method": "GET" },
"update": { "href": "/tasks/1", "method": "PUT" },
"delete": { "href": "/tasks/1", "method": "DELETE" },
"assign": { "href": "/tasks/1/assignee", "method": "POST" },
"comments": { "href": "/tasks/1/comments", "method": "GET" }
}
}
9. 包括的な例:タスク管理APIの完全な設計
アリスのチームは、タスク管理システム向けに、リソースの定義からエラー処理に至るまでを網羅した、完全なRESTful APIを設計しました。
▶ 例:タスク管理 API の完全な実装
(1) リソースの定義
| リソース | パス | 説明 |
|---|---|---|
| タスクコレクション | /api/v1/tasks |
すべてのタスク |
| 単一タスク | /api/v1/tasks/:id |
指定タスク |
| タスクへのコメント | /api/v1/tasks/:id/comments |
特定のタスクへのコメント |
| タスクタグ | /api/v1/tasks/:id/tags |
特定のタスク用のタグ |
(2) メソッドのマッピングとリクエスト/レスポンス
const express = require('express');
const app = express();
app.use(express.json());
let tasks = [
{ id: 1, title: 'Design database schema', status: 'done', priority: 'high', createdAt: '2025-07-01T08:00:00Z' },
{ id: 2, title: 'Implement REST API', status: 'in-progress', priority: 'high', createdAt: '2025-07-02T09:00:00Z' }
];
let nextId = 3;
// GET /api/v1/tasks — Get the task list
app.get('/api/v1/tasks', (req, res) => {
const { status, priority, page = 1, limit = 20 } = req.query;
let result = [...tasks];
if (status) result = result.filter(t => t.status === status);
if (priority) result = result.filter(t => t.priority === priority);
const start = (page - 1) * limit;
const data = result.slice(start, start + Number(limit));
res.json({
data,
pagination: {
page: Number(page),
limit: Number(limit),
total: result.length,
totalPages: Math.ceil(result.length / Number(limit))
}
});
});
// GET /api/v1/tasks/:id — Get a Single Task
app.get('/api/v1/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
res.json({
data: task,
_links: {
self: { href: `/api/v1/tasks/${task.id}` },
update: { href: `/api/v1/tasks/${task.id}`, method: 'PUT' },
delete: { href: `/api/v1/tasks/${task.id}`, method: 'DELETE' },
comments: { href: `/api/v1/tasks/${task.id}/comments` }
}
});
});
// POST /api/v1/tasks — Create a Task
app.post('/api/v1/tasks', (req, res) => {
const { title, priority } = req.body;
if (!title) {
return res.status(400).json({
error: 'Validation failed',
details: [{ field: 'title', message: 'Title is required' }],
timestamp: new Date().toISOString()
});
}
const task = {
id: nextId++,
title,
status: 'pending',
priority: priority || 'medium',
createdAt: new Date().toISOString()
};
tasks.push(task);
res.status(201).location(`/api/v1/tasks/${task.id}`).json({ data: task });
});
// PUT /api/v1/tasks/:id — Full Update
app.put('/api/v1/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
const { title, status, priority } = req.body;
if (!title || !status) {
return res.status(400).json({
error: 'Validation failed',
details: [
...(!title ? [{ field: 'title', message: 'Title is required' }] : []),
...(!status ? [{ field: 'status', message: 'Status is required' }] : [])
],
timestamp: new Date().toISOString()
});
}
tasks[idx] = { id: tasks[idx].id, title, status, priority: priority || 'medium', createdAt: tasks[idx].createdAt };
res.json({ data: tasks[idx] });
});
// PATCH /api/v1/tasks/:id — Partial Update
app.patch('/api/v1/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
Object.assign(task, req.body);
res.json({ data: task });
});
// DELETE /api/v1/tasks/:id — Delete Task
app.delete('/api/v1/tasks/:id', (req, res) => {
const idx = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (idx === -1) {
return res.status(404).json({
error: 'Task not found',
details: [{ field: 'id', message: `No task with id ${req.params.id}` }],
timestamp: new Date().toISOString()
});
}
tasks.splice(idx, 1);
res.status(204).end();
});
app.listen(3000, () => console.log('Task API running on port 3000'));
(3) リクエストとレスポンスのクイックリファレンス
# Create a Task
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Write documentation","priority":"high"}'
# Get the task list(Filter + Pagination)
curl http://localhost:3000/api/v1/tasks?status=pending&page=1&limit=10
# Partial Update
curl -X PATCH http://localhost:3000/api/v1/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status":"done"}'
# Delete Task
curl -X DELETE http://localhost:3000/api/v1/tasks/2
// POST Created successfully → 201
Status: 201 Created
Location: /api/v1/tasks/3
{ "data": { "id": 3, "title": "Write documentation", "status": "pending", "priority": "high", "createdAt": "2025-07-03T10:30:00Z" } }
// PATCH Update successful → 200
{ "data": { "id": 1, "title": "Design database schema", "status": "done", "priority": "high", "createdAt": "2025-07-01T08:00:00Z" } }
// DELETE Success → 204
Status: 204 No Content
(empty body)
// 404 Error
{ "error": "Task not found", "details": [{ "field": "id", "message": "No task with id 999" }], "timestamp": "2025-07-03T10:30:00Z" }
// 400 Validation Error
{ "error": "Validation failed", "details": [{ "field": "title", "message": "Title is required" }], "timestamp": "2025-07-03T10:30:00Z" }
❓ よくある質問
/api/v1/) が最も直感的です。ブラウザで直接テストでき、多くの公開APIで採用されています。リクエストヘッダーによるバージョン管理はよりRESTfulですが、デバッグが複雑になります。クエリパラメータは最もシンプルですが、見落とされがちです。初心者にはURLパスのバージョン管理をお勧めします。Content-Typeヘッダーを使用してネゴシエートすることができます。/users/42/tasks/1/comments/5が深すぎる場合は、/comments/5や/tasks/1/comments/5に変更することができます。/tasks/batch を使用してレコードを作成すること、配列を含む PATCH リクエスト /tasks を使用してレコードを一括更新すること、および DELETE リクエスト /tasks?ids=1,2,3 を使用してレコードを一括削除することが挙げられます。カスタムエンドポイントは、明確に文書化する必要があります。📖 まとめ
- 重要な概念とその活用方法
- 1 RESTアーキテクチャの原則:基本概念と活用方法
- 2 CRUDの基本概念と使い方、およびHTTPメソッドのマッピング
- URL設計ガイドラインの3つの基本概念と活用方法
- HTTPステータスコードの4つの主要な概念と使い方
- リクエストおよびレスポンス形式の5つの基本概念と使用方法
- APIバージョン管理戦略の6つの基本概念と活用方法
- REST成熟度モデルの7つの基本概念と活用方法
📝 練習問題
- このレッスンにあるすべてのコード例を完成させ、それぞれが正しく動作することを確認してください。
- 包括的な例を修正し、独自の拡張機能を追加する
- 公式ドキュメントを確認し、このレッスンで取り上げられていないAPIを1~2つ見つけ、それらのテストコードを作成してください。
- 振り返り:このレッスンで学んだことを、実際のプロジェクトにどのように活かしますか?
- このレッスンで学んだことと、これまでのレッスンの内容を組み合わせて、小さなプロジェクトを作成してみてください。



