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}

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

リソース構造:

/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=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設計の落とし穴

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

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

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

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

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

  1. 過度なネスト

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

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

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

関連記事

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

参考リンク

← 一覧に戻る