REST APIの基本原則
REST(Representational State Transfer)は、Webサービス設計のためのアーキテクチャスタイルです。6つの制約に基づいて設計されます。
flowchart TB
subgraph REST["REST 6つの制約"]
CS["1. Client-Server<br/>クライアント・サーバー分離"]
SL["2. Stateless<br/>各リクエストは独立"]
CA["3. Cacheable<br/>キャッシュ可能かを明示"]
UI["4. Uniform Interface<br/>統一インターフェース"]
LS["5. Layered System<br/>階層化システム"]
CD["6. Code on Demand<br/>(オプション)"]
end
Client["Client<br/>(UI)"] <-->|HTTP| Server["Server<br/>(Data)"]
リソース設計
命名規則
良い例:
/users- 複数形、名詞/users/123- リソースID/users/123/orders- ネストしたリソース/users/123/orders/456- 具体的なサブリソース
悪い例:
/getUsers- 動詞は使わない/user- 単数形は避ける/Users- 大文字は避ける/user-list- リスト表現は不要/api/v1/get-all-users- 動詞+冗長な表現
階層関係:
/organizations/{orgId}
/organizations/{orgId}/teams/{teamId}
/organizations/{orgId}/teams/{teamId}/members
深いネストは避ける (3階層まで):
- ❌
/organizations/{id}/teams/{id}/projects/{id}/tasks - ✅
/tasks?projectId={id}
コレクションとドキュメント
リソース構造:
/users → コレクション (User[])
/users/123 → ドキュメント (User)
/users/123/avatar → サブリソース (単一)
/users/123/orders → サブコレクション (Order[])
/users/123/orders/456 → サブドキュメント (Order)
HTTPメソッドの使い分け
| メソッド | 用途 | 冪等性 | 安全性 |
|---|---|---|---|
| GET | リソースの取得 | ○ | ○ |
| POST | リソースの作成 | × | × |
| PUT | リソースの完全置換 | ○ | × |
| PATCH | リソースの部分更新 | × | × |
| DELETE | リソースの削除 | ○ | × |
| HEAD | メタデータの取得 | ○ | ○ |
| OPTIONS | 利用可能メソッドの確認 | ○ | ○ |
// Express.js での実装例
import express from 'express';
const router = express.Router();
// GET: リソースの取得
router.get('/users', async (req, res) => {
const users = await userService.findAll(req.query);
res.json({ data: users });
});
router.get('/users/:id', async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ data: user });
});
// POST: リソースの作成
router.post('/users', async (req, res) => {
const user = await userService.create(req.body);
res.status(201).json({ data: user });
});
// PUT: リソースの完全置換
router.put('/users/:id', async (req, res) => {
const user = await userService.replace(req.params.id, req.body);
res.json({ data: user });
});
// PATCH: リソースの部分更新
router.patch('/users/:id', async (req, res) => {
const user = await userService.update(req.params.id, req.body);
res.json({ data: user });
});
// DELETE: リソースの削除
router.delete('/users/:id', async (req, res) => {
await userService.delete(req.params.id);
res.status(204).send();
});
HTTPステータスコード
ステータスコード一覧
2xx 成功:
| コード | 説明 |
|---|---|
| 200 OK | 成功(GET, PUT, PATCH) |
| 201 Created | 作成成功(POST) |
| 204 No Content | 成功、レスポンスボディなし(DELETE) |
3xx リダイレクト:
| コード | 説明 |
|---|---|
| 301 Moved Permanently | 恒久的な移動 |
| 304 Not Modified | キャッシュ有効 |
4xx クライアントエラー:
| コード | 説明 |
|---|---|
| 400 Bad Request | 不正なリクエスト |
| 401 Unauthorized | 認証が必要 |
| 403 Forbidden | アクセス権限なし |
| 404 Not Found | リソースが存在しない |
| 405 Method Not Allowed | 許可されていないメソッド |
| 409 Conflict | リソースの競合 |
| 422 Unprocessable Entity | バリデーションエラー |
| 429 Too Many Requests | レート制限超過 |
5xx サーバーエラー:
| コード | 説明 |
|---|---|
| 500 Internal Server Error | 内部エラー |
| 502 Bad Gateway | ゲートウェイエラー |
| 503 Service Unavailable | サービス利用不可 |
| 504 Gateway Timeout | タイムアウト |
エラーレスポンス設計
// 統一されたエラーレスポンス形式
interface ErrorResponse {
error: {
code: string; // 機械可読なエラーコード
message: string; // 人間可読なメッセージ
details?: ErrorDetail[]; // 詳細情報(バリデーションエラーなど)
requestId?: string; // デバッグ用リクエストID
documentation?: string; // ドキュメントへのリンク
};
}
interface ErrorDetail {
field: string;
message: string;
code: string;
}
// 実装例
class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: ErrorDetail[]
) {
super(message);
}
}
// エラーハンドラーミドルウェア
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
const requestId = req.headers['x-request-id'] || generateRequestId();
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
requestId,
},
});
}
// 予期しないエラー
console.error(`[${requestId}] Unexpected error:`, err);
return res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId,
},
});
}
// バリデーションエラーの例
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{
"field": "email",
"message": "Invalid email format",
"code": "INVALID_FORMAT"
},
{
"field": "password",
"message": "Password must be at least 8 characters",
"code": "TOO_SHORT"
}
],
"requestId": "req_abc123"
}
}
ページネーション
| 方式 | 例 | メリット | デメリット |
|---|---|---|---|
| オフセットベース | GET /users?limit=10&offset=20 | シンプル、任意ページにジャンプ可能 | 大量データで遅い、データ追加時に重複/欠落 |
| カーソルベース | GET /users?limit=10&cursor=eyJpZCI6MTAwfQ | 高速、データ変更に強い | 任意ページへのジャンプ不可 |
| キーセットベース | GET /users?limit=10&after_id=100 | シンプルで高速 | ソート順序に依存 |
推奨: リアルタイムデータ → カーソル / 静的データ → オフセット
実装例
// カーソルベースページネーション
interface PaginatedResponse<T> {
data: T[];
pagination: {
cursor: string | null;
hasMore: boolean;
totalCount?: number;
};
}
async function getUsers(cursor?: string, limit = 10): Promise<PaginatedResponse<User>> {
let query = db.users.orderBy('createdAt', 'desc');
if (cursor) {
const decoded = decodeCursor(cursor);
query = query.where('createdAt', '<', decoded.createdAt);
}
const users = await query.limit(limit + 1).exec();
const hasMore = users.length > limit;
const data = hasMore ? users.slice(0, -1) : users;
return {
data,
pagination: {
cursor: data.length > 0 ? encodeCursor(data[data.length - 1]) : null,
hasMore,
},
};
}
function encodeCursor(user: User): string {
return Buffer.from(JSON.stringify({ id: user.id, createdAt: user.createdAt })).toString('base64');
}
function decodeCursor(cursor: string): { id: string; createdAt: Date } {
return JSON.parse(Buffer.from(cursor, 'base64').toString());
}
バージョニング
| 方式 | 例 | メリット | デメリット |
|---|---|---|---|
| URLパス | /api/v1/users | 明確、キャッシュしやすい | URL変更が必要 |
| クエリパラメータ | /api/users?version=1 | 柔軟 | キャッシュ困難 |
| ヘッダー | Accept: application/vnd.api+json;version=1 | URLがクリーン | 発見しにくい |
| メディアタイプ | Accept: application/vnd.myapi.v1+json | 標準的 | 複雑 |
// URLパス方式(推奨)
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// ヘッダー方式
app.use((req, res, next) => {
const version = req.headers['api-version'] || '1';
req.apiVersion = parseInt(version);
next();
});
フィルタリング・ソート・検索
# フィルタリング
GET /users?status=active&role=admin
GET /orders?created_after=2024-01-01&created_before=2024-12-31
GET /products?price_min=100&price_max=500
# ソート
GET /users?sort=created_at # 昇順
GET /users?sort=-created_at # 降順
GET /users?sort=name,-created_at # 複数フィールド
# 検索
GET /users?q=john # 全文検索
GET /users?search[name]=john # フィールド指定
# フィールド選択
GET /users?fields=id,name,email # 必要なフィールドのみ
GET /users?include=orders,profile # リレーションの含有
// クエリビルダーの実装
function buildQuery(params: QueryParams) {
let query = db.users;
// フィルタリング
if (params.status) {
query = query.where('status', '=', params.status);
}
// ソート
if (params.sort) {
const fields = params.sort.split(',');
for (const field of fields) {
const order = field.startsWith('-') ? 'desc' : 'asc';
const column = field.replace(/^-/, '');
query = query.orderBy(column, order);
}
}
// フィールド選択
if (params.fields) {
const columns = params.fields.split(',');
query = query.select(columns);
}
return query;
}
レート制限
// レート制限ヘッダー
app.use((req, res, next) => {
const rateLimit = getRateLimit(req);
res.set({
'X-RateLimit-Limit': rateLimit.limit,
'X-RateLimit-Remaining': rateLimit.remaining,
'X-RateLimit-Reset': rateLimit.resetAt,
});
if (rateLimit.remaining <= 0) {
return res.status(429).json({
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests',
retryAfter: rateLimit.resetAt,
},
});
}
next();
});
認証・認可
// Bearer Token認証
const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'Missing or invalid authorization header',
},
});
}
const token = authHeader.substring(7);
try {
const payload = await verifyToken(token);
req.user = payload;
next();
} catch (err) {
return res.status(401).json({
error: {
code: 'INVALID_TOKEN',
message: 'Token is invalid or expired',
},
});
}
};
// ロールベース認可
const requireRole = (...roles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'Insufficient permissions',
},
});
}
next();
};
};
// 使用例
router.delete('/users/:id', authMiddleware, requireRole('admin'), deleteUser);
OpenAPI(Swagger)ドキュメント
# openapi.yaml
openapi: 3.0.3
info:
title: User API
version: 1.0.0
description: User management API
paths:
/users:
get:
summary: List all users
tags: [Users]
parameters:
- name: limit
in: query
schema:
type: integer
default: 10
- name: cursor
in: query
schema:
type: string
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/UserListResponse'
components:
schemas:
User:
type: object
properties:
id:
type: string
email:
type: string
format: email
name:
type: string
required: [id, email, name]
現場で学んだAPI設計の落とし穴
実際のプロジェクトでの失敗例
- レスポンス形式の不統一
プロジェクト初期に統一ルールを決めずに開発を進めた結果、エンドポイントごとに data, result, response と異なるキーが使われていました。後からの統一は既存クライアントへの影響が大きく、バージョニングで対応することになりました。
- エラーコードの設計ミス
HTTPステータスコードだけに頼り、独自のエラーコード体系を作らなかったため、クライアント側で「400エラー」が「バリデーションエラー」なのか「ビジネスルール違反」なのか区別できない状態になりました。
- 過度なネスト
/users/{id}/orders/{id}/items/{id}/reviews のような深いネストを作成し、URL長制限に引っかかったり、パフォーマンスが悪化したりしました。3階層以上はクエリパラメータで対応すべきでした。
API設計レビューチェックリスト
- 全エンドポイントでレスポンス形式が統一されているか
- エラーレスポンスに機械可読なエラーコードがあるか
- ページネーションの方式が決まっているか
- 認証/認可の方式が統一されているか
- バージョニング戦略が決まっているか
- レート制限の設計がされているか
関連記事
API設計をさらに深く学ぶために:
- GraphQL vs REST - APIパラダイムの比較
- APIバージョニング戦略 - 後方互換性の維持方法
- レート制限の実装 - API保護のベストプラクティス
- JWTの仕組み - トークンベース認証
- 認証・認可パターン - セキュアなAPI設計
- API Gatewayパターン - マイクロサービスでのAPI管理
参考リンク
- RESTful Web APIs (O’Reilly)
- HTTP API Design Guide
- Microsoft REST API Guidelines
- JSON:API Specification