REST API設計原則 - スケーラブルで保守性の高いAPI設計

2025.12.02

公式ドキュメント

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}

コレクションとドキュメント

URL
/usersコレクション (User[])
/users/123ドキュメント (User)
/users/123/avatarサブリソース (単一)
/users/123/ordersサブコレクション (Order[])
/users/123/orders/456サブドキュメント (Order)

HTTPメソッドの使い分け

ポイント: 冪等性(同じリクエストを何度送っても同じ結果になる性質)はAPI設計の重要な概念です。GET・PUT・DELETEは冪等、POSTは非冪等です。

メソッド用途冪等性安全性
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=1URLがクリーン発見しにくい
メディアタイプ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設計の落とし穴

注意: 以下の失敗例はプロジェクト初期に設計ルールを決めないことで起きやすい問題です。API設計ガイドラインを最初に策定しましょう。

実際のプロジェクトでの失敗例

  1. レスポンス形式の不統一

プロジェクト初期に統一ルールを決めずに開発を進めた結果、エンドポイントごとに data, result, response と異なるキーが使われていました。後からの統一は既存クライアントへの影響が大きく、バージョニングで対応することになりました。

  1. エラーコードの設計ミス

HTTPステータスコードだけに頼り、独自のエラーコード体系を作らなかったため、クライアント側で「400エラー」が「バリデーションエラー」なのか「ビジネスルール違反」なのか区別できない状態になりました。

  1. 過度なネスト

/users/{id}/orders/{id}/items/{id}/reviews のような深いネストを作成し、URL長制限に引っかかったり、パフォーマンスが悪化したりしました。3階層以上はクエリパラメータで対応すべきでした。

API設計レビューチェックリスト

  • 全エンドポイントでレスポンス形式が統一されているか
  • エラーレスポンスに機械可読なエラーコードがあるか
  • ページネーションの方式が決まっているか
  • 認証/認可の方式が統一されているか
  • バージョニング戦略が決まっているか
  • レート制限の設計がされているか

まとめ

この記事は、名前だけで覚えるより、登場人物、処理の流れ、失敗しやすい点をセットで理解することが重要です。実装で使う前に、公式ドキュメントと実際のリクエストやログを確認してください。

関連記事

API設計をさらに深く学ぶために:

参考リンク

参考リソース

← 一覧に戻る
PR
PR
PR
PR