REST API設計完全ガイド〜エンドポイント命名・ステータスコード・バージョニング・HATEOAS【2026年版】〜

「REST API の設計で迷子になる」「URL は複数形なのか単数形なのか」「ステータスコードを正しく使い分けたい」「バージョニングは URL かヘッダーか」「HATEOAS は本当に必要なのか」。REST API 設計は仕様書(Roy Fielding 博士の論文)があるにもかかわらず、現場では流派が分かれて混乱しがちです。本記事は Node.js 22 + Express 5 + TypeScript 5 + Zod 4 + OpenAPI 3.1 を前提に、40 個超のコピペで動くコードで「迷いどころ」をすべて潰しながら、現場で通用する REST API 設計の完全な型を提示します。RFC 7807 (problem+json)、Cursor ページネーション、ETag キャッシュ、レート制限、OWASP API Top 10、OpenAPI 連動の Vitest + Supertest テストまで、これ 1 本で必要十分です。

この記事を最後まで読むと身につくこと

  • Roy Fielding の REST 6 つの制約とリソース指向設計の「正しい型」
  • URL 命名・HTTP 動詞・ステータスコード・エラー応答(RFC 7807)の現場標準
  • 3 種類のバージョニング戦略、Offset/Cursor 両ページネーション、ETag/Last-Modified キャッシュ
  • Express 5 + Zod で書く CRUD ・ファイルアップロード・認証(Bearer/API Key/Basic)
  • OpenAPI 3.1 を 単一の真実として Swagger UI / Redoc / Prism Mock / Vitest 連動
  • OWASP API Security Top 10 対策・CORS・レート制限・SSRF/SQLi/CSRF 防御
  • JSON:API・HATEOAS の使いどころ、tRPC / GraphQL との明確な棲み分け
  1. 1. REST とは何か:Fielding 博士の 6 つの制約を 5 分で
    1. 1.1 REST の正しい定義
    2. 1.2 6 つの制約(Constraints)
    3. 1.3 Richardson 成熟度モデル(REST の達成度)
    4. 1.4 環境準備(本記事のスタック)
    5. 1.5 package.json scripts
  2. 2. リソース指向設計と URL 命名の現場標準
    1. 2.1 リソースとは「名詞」である
    2. 2.2 命名規則:複数形・ハイフン・小文字
    3. 2.3 ネスト構造(2 階層まで)
    4. 2.4 サブリソース vs フィルタ
    5. 2.5 アクションが必要な場合(コントローラーリソース)
  3. 3. HTTP 動詞とステータスコードの設計表
    1. 3.1 HTTP 動詞の意味と冪等性
    2. 3.2 PUT と PATCH の違い(現場で最も混乱)
    3. 3.3 ステータスコード厳選 25(これだけで現場の 99% を網羅)
    4. 3.4 401 vs 403 の使い分け(現場頻出)
    5. 3.5 400 vs 422 の使い分け
  4. 4. プロジェクト雛形:Express 5 + TypeScript + Zod
    1. 4.1 ディレクトリ構成
    2. 4.2 server.ts(エントリポイント)
    3. 4.3 app.ts(共通ミドルウェアを集約)
    4. 4.4 Zod スキーマ(User リソース)
  5. 5. CRUD 実装:エンドポイントの「型」を見せる
    1. 5.1 in-memory リポジトリ(以降のサンプルで使う)
    2. 5.2 GET /users(一覧:ページネーション込み)
    3. 5.3 GET /users/:id(個別取得)
    4. 5.4 POST /users(作成:201 + Location)
    5. 5.5 PUT /users/:id(全置換)
    6. 5.6 PATCH /users/:id(部分更新)
    7. 5.7 DELETE /users/:id(204 No Content)
    8. 5.8 POST /users:bulk(バルク作成パターン)
  6. 6. エラーレスポンス標準:RFC 7807(application/problem+json)
    1. 6.1 なぜ RFC 7807 か
    2. 6.2 problem ヘルパ実装
    3. 6.3 エラーハンドラ・404 ハンドラ
    4. 6.4 レスポンス例
  7. 7. 認証:Bearer / API Key / Basic を正しく使い分ける
    1. 7.1 認証スキームの選び方
    2. 7.2 Bearer 認証ミドルウェア
    3. 7.3 スコープ(権限)チェック
    4. 7.4 API Key 方式
  8. 8. バージョニング戦略 3 種類の比較
    1. 8.1 URL パス方式(最も普及)
    2. 8.2 ヘッダ方式(Accept-Version)
    3. 8.3 Media Type 方式(本格 REST)
    4. 8.4 バージョン分岐ミドルウェア
    5. 8.5 deprecation を伝える
  9. 9. ページネーション:Offset と Cursor を両対応
    1. 9.1 Offset 方式(実装が単純、大規模に弱い)
    2. 9.2 Cursor 方式(大規模で堅牢、推奨)
    3. 9.3 Cursor の実装(base64url で id をエンコード)
    4. 9.4 Link ヘッダ方式(GitHub 互換)
  10. 10. フィルタ・ソート・フィールド選択・スパースフィールド
    1. 10.1 フィルタ(query parameter)
    2. 10.2 ソート(sort=field, sort=-field)
    3. 10.3 フィールド選択(?fields=)
    4. 10.4 関連の Eager Load(?include=)
  11. 11. キャッシュ:ETag / Last-Modified / Cache-Control
    1. 11.1 Cache-Control の基本
    2. 11.2 ETag の実装(条件付き GET → 304)
    3. 11.3 Last-Modified の実装
    4. 11.4 楽観的ロック(If-Match)
  12. 12. レート制限と圧縮
    1. 12.1 express-rate-limit(IP / API Key 別)
    2. 12.2 圧縮(gzip / brotli)
    3. 12.3 ヘッダで返す情報
  13. 13. CORS と Preflight
    1. 13.1 単純リクエストとプリフライト
    2. 13.2 cors ミドルウェアの本番設定
  14. 14. ファイルアップロード(multipart / 署名付き URL)
    1. 14.1 multipart 直接アップロード(小さいファイル向け)
    2. 14.2 大きいファイル:署名付き URL(推奨)
  15. 15. HATEOAS と JSON:API / JSON-LD
    1. 15.1 HATEOAS とは(Level 3 の核心)
    2. 15.2 単純な HATEOAS 例
    3. 15.3 JSON:API 仕様(構造を強制した REST)
    4. 15.4 JSON-LD(構造化データ・SEO 連動)
  16. 16. OpenAPI 3.1 を真実の単一情報源(SSoT)に
    1. 16.1 なぜ OpenAPI か
    2. 16.2 openapi.yaml(最小サンプル)
    3. 16.3 Swagger UI を組み込む
    4. 16.4 Redoc(美しい静的ドキュメント)
    5. 16.5 Prism Mock(契約先行でフロントが先に動ける)
    6. 16.6 Redocly で lint / bundle
  17. 17. テスト:Vitest + Supertest と契約テスト
    1. 17.1 vitest.config.ts
    2. 17.2 Supertest による E2E テスト
    3. 17.3 OpenAPI 契約テスト(jest-openapi 風)
  18. 18. CI/CD パイプライン
    1. 18.1 GitHub Actions(lint + test + openapi 検証)
    2. 18.2 Spectral によるカスタム lint
  19. 19. OWASP API Security Top 10(2023)対策
    1. 19.1 API1 Broken Object Level Authorization (BOLA)
    2. 19.2 API3 Broken Object Property Level Authorization
    3. 19.3 API4 Unrestricted Resource Consumption
    4. 19.4 API7 SSRF(Server-Side Request Forgery)対策
    5. 19.5 API8 Security Misconfiguration → helmet 必須
    6. 19.6 SQL Injection(プレースホルダ必須)
    7. 19.7 CSRF(Cookie ベース API のみ)
  20. 20. パフォーマンス:HTTP/2・HTTP/3・ストリーミング
    1. 20.1 HTTP/2 を有効化(Node.js 標準モジュール)
    2. 20.2 ストリーミング応答(大容量 CSV など)
    3. 20.3 SSE(Server-Sent Events)
  21. 21. REST vs tRPC vs GraphQL の選定基準
    1. 21.1 比較表
    2. 21.2 選定フローチャート
  22. 22. Postman / Bruno コレクション
    1. 22.1 Bruno(Git 管理可能・OSS)
    2. 22.2 Postman 環境変数
  23. 23. ロギング・トレーシング・監視
    1. 23.1 pino による構造化ログ
    2. 23.2 OpenTelemetry(Trace ID をレスポンスに)
  24. 24. 設計チェックリスト(納品前の最終確認)
  25. 25. まとめ:REST 設計の 7 原則

1. REST とは何か:Fielding 博士の 6 つの制約を 5 分で

1.1 REST の正しい定義

REST(Representational State Transfer)は Roy Fielding 博士が 2000 年の博士論文で定義した アーキテクチャスタイルです。プロトコルでも仕様でもなく、満たすべき 6 つの制約の集合で、HTTP の能力を最大限活用する設計思想を示します。「JSON を返す Web API = REST」ではない、という点をまず押さえてください。

1.2 6 つの制約(Constraints)

  1. クライアント・サーバー分離(関心の分離)
  2. ステートレス(各リクエストが必要情報を完備)
  3. キャッシュ可能(レスポンスがキャッシュ可否を明示)
  4. 統一インターフェース(URI・HTTP 動詞・自己記述メッセージ・HATEOAS)
  5. 階層化システム(プロキシ・LB・CDN を透過的に挟める)
  6. コードオンデマンド(オプション、JavaScript 配信などはここ)

1.3 Richardson 成熟度モデル(REST の達成度)

REST の達成度を測る Leonard Richardson の 4 段階モデルがあります。Level 0(HTTP を RPC 用トンネルにしているだけ)→ Level 1(リソース URI)→ Level 2(HTTP 動詞とステータスコードを正しく使う)→ Level 3(HATEOAS)。現場の目標は Level 2 を確実に達成することです。Level 3 は要件次第で採用します。

1.4 環境準備(本記事のスタック)

# Node.js 22 LTS / npm 10 / TypeScript 5.6
node -v
# v22.11.0
npm -v
# 10.9.0

mkdir rest-api-handson && cd rest-api-handson
npm init -y
npm i -D typescript @types/node tsx vitest supertest @types/supertest @vitest/coverage-v8
npm i express@5 zod cors helmet express-rate-limit etag compression
npm i -D @types/express @types/cors @types/compression
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext 
  --esModuleInterop --strict --outDir dist

1.5 package.json scripts

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "openapi:bundle": "redocly bundle openapi.yaml -o openapi.bundled.yaml",
    "mock": "prism mock openapi.yaml --port 4010"
  }
}

2. リソース指向設計と URL 命名の現場標準

2.1 リソースとは「名詞」である

REST の中核は「動詞ではなくリソース(名詞)で世界をモデル化する」ことです。/getUsers/createUser は RPC 的設計で REST ではありません。動作は HTTP 動詞対象は URLで表します。

# NG(動詞をURLに入れる:RPC)
GET  /getUsers
POST /createUser
POST /deleteUserById?id=1
POST /searchProducts

# OK(リソース指向)
GET    /users
POST   /users
DELETE /users/1
GET    /products?q=keyword

2.2 命名規則:複数形・ハイフン・小文字

# 1. リソースは複数形(collection)
/users        # 一覧
/users/123    # 個別 (item)

# 2. 単語の区切りは「ハイフン」
/order-items   ✓
/orderItems    ✗ (camelCase は URL では非推奨)
/order_items   ✗ (アンダースコアは可読性が落ちる)

# 3. 小文字
/Users         ✗
/users         ✓

# 4. 末尾スラッシュは付けない(一貫性)
/users/        ✗
/users         ✓

# 5. 拡張子は付けない(.json などは Accept ヘッダで)
/users.json    ✗
/users + Accept: application/json  ✓

2.3 ネスト構造(2 階層まで)

# 親子関係を表現
GET  /users/123/orders        # ユーザー123の注文一覧
GET  /users/123/orders/456    # ユーザー123の注文456
POST /users/123/orders        # ユーザー123の新規注文

# 3階層以上はネストせず、リソースで切る(コレクションは Top-Level)
NG: /users/123/orders/456/items/789
OK: /order-items/789  + /orders/456 で参照を貼る

2.4 サブリソース vs フィルタ

# 強い親子関係 → ネスト
GET /users/123/orders

# 弱い関連・検索 → クエリパラメータ
GET /orders?userId=123&status=paid&from=2026-01-01

# 集計エンドポイントは合成リソース化
GET /reports/sales-monthly?year=2026&month=5

2.5 アクションが必要な場合(コントローラーリソース)

「ログイン」「メール送信」「再ビルド」のような名詞化しにくい操作は、コントローラーリソースとして動詞 URL を許容します。乱発は禁物です。

# 例外的に許容される動詞 URL
POST /sessions             # ログイン(セッションを作る、と捉える)
POST /password-resets      # パスワードリセット要求
POST /orders/123/cancel    # キャンセル(状態遷移)
POST /jobs/456/retry       # ジョブ再実行

3. HTTP 動詞とステータスコードの設計表

3.1 HTTP 動詞の意味と冪等性

動詞    用途              安全 冪等  例
GET     取得              ◯    ◯    GET /users/1
HEAD    ヘッダ取得         ◯    ◯    HEAD /users/1
OPTIONS 許可確認/CORS      ◯    ◯    OPTIONS /users
POST    作成・任意操作      ✗    ✗    POST /users
PUT     全体置換           ✗    ◯    PUT /users/1
PATCH   部分更新           ✗    ✗(慣習的に冪等推奨)
DELETE  削除              ✗    ◯    DELETE /users/1

3.2 PUT と PATCH の違い(現場で最も混乱)

PUT は「全置換」PATCH は「差分適用」です。PUT で {name:"taro"} だけ送ると、他のフィールドは undefined 扱いで消える設計が原則です。PATCH の本格仕様は RFC 6902(JSON Patch) と RFC 7396(JSON Merge Patch)。実用では JSON Merge Patch がよく使われます。

# PUT(全置換)
PUT /users/1
{"name":"taro","email":"t@example.com","age":30}

# PATCH(JSON Merge Patch - RFC 7396)
PATCH /users/1
Content-Type: application/merge-patch+json
{"name":"taro"}        # name だけ更新、他は不変
{"age":null}           # null 指定で削除

# PATCH(JSON Patch - RFC 6902)
PATCH /users/1
Content-Type: application/json-patch+json
[
  {"op":"replace","path":"/name","value":"taro"},
  {"op":"remove","path":"/age"}
]

3.3 ステータスコード厳選 25(これだけで現場の 99% を網羅)

# 2xx 成功
200 OK                  GETやPATCH等、レスポンスボディあり
201 Created             POSTで新規作成成功(Location ヘッダで新URL)
202 Accepted            非同期処理を受理(まだ完了していない)
204 No Content          DELETE 成功、レスポンスボディなし
206 Partial Content     Range リクエスト成功

# 3xx リダイレクト
301 Moved Permanently   恒久移動(SEO的にURL移転で利用)
304 Not Modified        ETag/Last-Modified ヒット(キャッシュ可)
307 Temporary Redirect  メソッドを維持した一時リダイレクト
308 Permanent Redirect  メソッドを維持した恒久リダイレクト

# 4xx クライアントエラー
400 Bad Request         不正なリクエスト(構文・型エラー)
401 Unauthorized        未認証(認証情報なし/無効)
403 Forbidden           認証済みだが権限なし
404 Not Found           リソース不在
405 Method Not Allowed  動詞が許可されていない(Allow ヘッダ必須)
406 Not Acceptable      Accept ヘッダと提供形式が合わない
409 Conflict            状態競合(重複登録・楽観ロック失敗)
410 Gone                恒久削除済み
413 Payload Too Large   ボディ大きすぎ
415 Unsupported Media Type  Content-Type が非対応
422 Unprocessable Entity  バリデーションエラー(Zod 等)
429 Too Many Requests   レート制限

# 5xx サーバーエラー
500 Internal Server Error  汎用サーバーエラー
502 Bad Gateway          上流エラー
503 Service Unavailable  メンテ・過負荷(Retry-After で復帰目安)
504 Gateway Timeout      上流タイムアウト

3.4 401 vs 403 の使い分け(現場頻出)

# 401: 「あなたが誰か分からない」
- Authorization ヘッダなし
- トークン期限切れ
- トークンが無効
→ WWW-Authenticate ヘッダで認証スキームを返す

# 403: 「あなたが誰かは分かるが、権限がない」
- 認証は成功
- 該当リソースへのアクセス権なし
- IPホワイトリスト外

3.5 400 vs 422 の使い分け

# 400 Bad Request
- JSON 構文エラー
- 必須ヘッダ欠落
- リクエスト全体が壊れている

# 422 Unprocessable Entity
- 構文は正しいが、ビジネスルール上不正
- バリデーションエラー(email 形式 NG など)
- 既存リソースとの整合性違反

4. プロジェクト雛形:Express 5 + TypeScript + Zod

4.1 ディレクトリ構成

rest-api-handson/
├── src/
│   ├── server.ts           # エントリポイント
│   ├── app.ts              # Express app の組立
│   ├── routes/
│   │   ├── users.ts
│   │   └── orders.ts
│   ├── schemas/
│   │   ├── user.ts         # Zod スキーマ
│   │   └── error.ts
│   ├── middlewares/
│   │   ├── error.ts
│   │   ├── auth.ts
│   │   ├── etag.ts
│   │   └── pagination.ts
│   └── lib/
│       └── problem.ts      # RFC 7807 ヘルパ
├── tests/
│   ├── users.test.ts
│   └── helpers.ts
├── openapi.yaml
├── tsconfig.json
└── package.json

4.2 server.ts(エントリポイント)

// src/server.ts
import { createApp } from "./app.js";

const port = Number(process.env.PORT ?? 3000);
const app = createApp();

app.listen(port, () => {
  // eslint-disable-next-line no-console
  console.log(`[server] listening on http://localhost:${port}`);
});

// graceful shutdown
const shutdown = () => {
  console.log("[server] shutting down...");
  process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

4.3 app.ts(共通ミドルウェアを集約)

// src/app.ts
import express from "express";
import cors from "cors";
import helmet from "helmet";
import compression from "compression";
import { rateLimit } from "express-rate-limit";
import { usersRouter } from "./routes/users.js";
import { ordersRouter } from "./routes/orders.js";
import { errorHandler } from "./middlewares/error.js";
import { notFoundHandler } from "./middlewares/error.js";

export const createApp = () => {
  const app = express();

  // セキュリティヘッダ(CSP / X-Frame-Options / etc.)
  app.use(helmet());

  // CORS(本番はオリジン明示)
  app.use(cors({
    origin: process.env.CORS_ORIGINS?.split(",") ?? "http://localhost:5173",
    credentials: true,
  }));

  // gzip / brotli 圧縮
  app.use(compression());

  // JSON / form-urlencoded
  app.use(express.json({ limit: "1mb" }));
  app.use(express.urlencoded({ extended: true, limit: "1mb" }));

  // レート制限(全体)
  app.use(rateLimit({
    windowMs: 60_000,
    limit: 100,
    standardHeaders: "draft-7",
    legacyHeaders: false,
  }));

  // 各リソース
  app.use("/v1/users", usersRouter);
  app.use("/v1/orders", ordersRouter);

  // 404 と 500
  app.use(notFoundHandler);
  app.use(errorHandler);

  return app;
};

4.4 Zod スキーマ(User リソース)

// src/schemas/user.ts
import { z } from "zod";

export const UserCreateSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(80),
  age: z.number().int().min(0).max(150).optional(),
});

export const UserUpdateSchema = UserCreateSchema.partial();

export const UserSchema = UserCreateSchema.extend({
  id: z.string().uuid(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

export type UserCreate = z.infer<typeof UserCreateSchema>;
export type UserUpdate = z.infer<typeof UserUpdateSchema>;
export type User = z.infer<typeof UserSchema>;

5. CRUD 実装:エンドポイントの「型」を見せる

5.1 in-memory リポジトリ(以降のサンプルで使う)

// src/lib/userRepo.ts
import { randomUUID } from "node:crypto";
import type { User, UserCreate, UserUpdate } from "../schemas/user.js";

const store = new Map<string, User>();

export const userRepo = {
  list(opts: { limit: number; offset: number; q?: string }) {
    const all = Array.from(store.values());
    const filtered = opts.q
      ? all.filter((u) => u.name.includes(opts.q!) || u.email.includes(opts.q!))
      : all;
    return {
      total: filtered.length,
      items: filtered.slice(opts.offset, opts.offset + opts.limit),
    };
  },
  get: (id: string) => store.get(id) ?? null,
  create(input: UserCreate): User {
    const now = new Date().toISOString();
    const user: User = { id: randomUUID(), ...input, createdAt: now, updatedAt: now };
    store.set(user.id, user);
    return user;
  },
  update(id: string, input: UserUpdate): User | null {
    const cur = store.get(id);
    if (!cur) return null;
    const next: User = { ...cur, ...input, updatedAt: new Date().toISOString() };
    store.set(id, next);
    return next;
  },
  delete: (id: string) => store.delete(id),
};

5.2 GET /users(一覧:ページネーション込み)

// src/routes/users.ts
import { Router } from "express";
import { z } from "zod";
import { userRepo } from "../lib/userRepo.js";
import { UserCreateSchema, UserUpdateSchema } from "../schemas/user.js";
import { problem } from "../lib/problem.js";

export const usersRouter = Router();

const ListQuery = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  offset: z.coerce.number().int().min(0).default(0),
  q: z.string().optional(),
  sort: z.enum(["createdAt", "-createdAt", "name", "-name"]).default("-createdAt"),
});

usersRouter.get("/", (req, res, next) => {
  const parsed = ListQuery.safeParse(req.query);
  if (!parsed.success) return next(problem(422, "Validation Failed", parsed.error));
  const { items, total } = userRepo.list(parsed.data);
  res.set("X-Total-Count", String(total));
  res.status(200).json({ data: items, meta: { total, ...parsed.data } });
});

5.3 GET /users/:id(個別取得)

usersRouter.get("/:id", (req, res, next) => {
  const user = userRepo.get(req.params.id);
  if (!user) return next(problem(404, "User not found"));
  res.status(200).json({ data: user });
});

5.4 POST /users(作成:201 + Location)

usersRouter.post("/", (req, res, next) => {
  const parsed = UserCreateSchema.safeParse(req.body);
  if (!parsed.success) return next(problem(422, "Validation Failed", parsed.error));
  const user = userRepo.create(parsed.data);
  res
    .status(201)
    .location(`/v1/users/${user.id}`)
    .json({ data: user });
});

5.5 PUT /users/:id(全置換)

usersRouter.put("/:id", (req, res, next) => {
  const parsed = UserCreateSchema.safeParse(req.body); // 全フィールド必須
  if (!parsed.success) return next(problem(422, "Validation Failed", parsed.error));
  const cur = userRepo.get(req.params.id);
  if (!cur) {
    // 「無ければ作る」設計の場合 ↓(冪等性を強める典型)
    const created = userRepo.create(parsed.data);
    return res.status(201).location(`/v1/users/${created.id}`).json({ data: created });
  }
  const next_ = userRepo.update(req.params.id, parsed.data);
  res.status(200).json({ data: next_ });
});

5.6 PATCH /users/:id(部分更新)

usersRouter.patch("/:id", (req, res, next) => {
  const parsed = UserUpdateSchema.safeParse(req.body);
  if (!parsed.success) return next(problem(422, "Validation Failed", parsed.error));
  const updated = userRepo.update(req.params.id, parsed.data);
  if (!updated) return next(problem(404, "User not found"));
  res.status(200).json({ data: updated });
});

5.7 DELETE /users/:id(204 No Content)

usersRouter.delete("/:id", (req, res, next) => {
  const ok = userRepo.delete(req.params.id);
  if (!ok) return next(problem(404, "User not found"));
  res.status(204).send(); // ボディなし
});

5.8 POST /users:bulk(バルク作成パターン)

// コレクションに対するアクションはコロン構文で表す(Google AIP-136)
usersRouter.post("/:bulk", (req, res, next) => {
  const Bulk = z.object({ users: z.array(UserCreateSchema).min(1).max(100) });
  const parsed = Bulk.safeParse(req.body);
  if (!parsed.success) return next(problem(422, "Validation Failed", parsed.error));
  const created = parsed.data.users.map((u) => userRepo.create(u));
  res.status(201).json({ data: created, meta: { count: created.length } });
});

6. エラーレスポンス標準:RFC 7807(application/problem+json)

6.1 なぜ RFC 7807 か

独自フォーマット({"error":"...", "code":"E001"})は組織ごとに乱立しがちです。RFC 7807(Problem Details for HTTP APIs)は IETF 標準で、type / title / status / detail / instance の 5 フィールドを共通語彙として定義します。OpenAPI でも参照しやすく、クライアント実装が再利用できます。

6.2 problem ヘルパ実装

// src/lib/problem.ts
import type { ZodError } from "zod";

export class ProblemError extends Error {
  status: number;
  type: string;
  title: string;
  detail?: string;
  errors?: unknown;
  constructor(status: number, title: string, detail?: string, errors?: unknown) {
    super(title);
    this.status = status;
    this.type = `https://example.com/errors/${status}`;
    this.title = title;
    this.detail = detail;
    this.errors = errors;
  }
}

export const problem = (status: number, title: string, zodErr?: ZodError) => {
  return new ProblemError(
    status,
    title,
    undefined,
    zodErr?.issues.map((i) => ({ path: i.path.join("."), message: i.message }))
  );
};

6.3 エラーハンドラ・404 ハンドラ

// src/middlewares/error.ts
import type { Request, Response, NextFunction } from "express";
import { ProblemError } from "../lib/problem.js";

export const notFoundHandler = (req: Request, res: Response) => {
  res
    .status(404)
    .type("application/problem+json")
    .json({
      type: "https://example.com/errors/404",
      title: "Not Found",
      status: 404,
      instance: req.originalUrl,
    });
};

export const errorHandler = (
  err: unknown,
  req: Request,
  res: Response,
  _next: NextFunction
) => {
  if (err instanceof ProblemError) {
    return res
      .status(err.status)
      .type("application/problem+json")
      .json({
        type: err.type,
        title: err.title,
        status: err.status,
        detail: err.detail,
        instance: req.originalUrl,
        errors: err.errors,
      });
  }
  console.error("[unhandled]", err);
  res
    .status(500)
    .type("application/problem+json")
    .json({
      type: "https://example.com/errors/500",
      title: "Internal Server Error",
      status: 500,
      instance: req.originalUrl,
    });
};

6.4 レスポンス例

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://example.com/errors/422",
  "title": "Validation Failed",
  "status": 422,
  "instance": "/v1/users",
  "errors": [
    {"path":"email","message":"Invalid email"},
    {"path":"name","message":"String must contain at least 1 character(s)"}
  ]
}

7. 認証:Bearer / API Key / Basic を正しく使い分ける

7.1 認証スキームの選び方

Bearer(JWT / Opaque Token) → ユーザー認証・SPAやモバイル
API Key (X-API-Key)         → サーバー間通信・公開APIの利用者識別
Basic                       → 内部ツール・初期セットアップ・HTTPS 必須
mTLS                        → 高セキュリティのサーバー間通信
OAuth 2.1 / OIDC            → サードパーティ連携・SSO

7.2 Bearer 認証ミドルウェア

// src/middlewares/auth.ts
import type { RequestHandler } from "express";
import { ProblemError } from "../lib/problem.js";

export interface AuthedUser { id: string; scopes: string[] }
declare module "express-serve-static-core" {
  interface Request { user?: AuthedUser }
}

export const bearerAuth: RequestHandler = (req, _res, next) => {
  const h = req.header("authorization");
  if (!h?.startsWith("Bearer ")) {
    // WWW-Authenticate ヘッダで認証スキームを返すのが正解
    _res.set("WWW-Authenticate", 'Bearer realm="api"');
    return next(new ProblemError(401, "Unauthorized", "Missing Bearer token"));
  }
  const token = h.slice("Bearer ".length);
  const user = verifyJwt(token); // 自前 or jose / jsonwebtoken
  if (!user) return next(new ProblemError(401, "Unauthorized", "Invalid token"));
  req.user = user;
  next();
};

function verifyJwt(_token: string): AuthedUser | null {
  // 省略:JWS 検証(jose 推奨)、署名・exp・iss・aud をチェック
  return { id: "demo", scopes: ["users:read", "users:write"] };
}

7.3 スコープ(権限)チェック

export const requireScope = (scope: string): RequestHandler => (req, _res, next) => {
  if (!req.user) return next(new ProblemError(401, "Unauthorized"));
  if (!req.user.scopes.includes(scope)) {
    return next(new ProblemError(403, "Forbidden", `Missing scope: ${scope}`));
  }
  next();
};

// 使い方
// usersRouter.delete("/:id", bearerAuth, requireScope("users:write"), handler);

7.4 API Key 方式

export const apiKeyAuth: RequestHandler = (req, _res, next) => {
  const key = req.header("x-api-key");
  if (!key) return next(new ProblemError(401, "Unauthorized", "Missing API Key"));
  // 定数時間比較を使う(タイミング攻撃対策)
  if (!isValidApiKey(key)) return next(new ProblemError(403, "Forbidden"));
  next();
};

import { timingSafeEqual } from "node:crypto";
function isValidApiKey(input: string) {
  const expected = process.env.API_KEY ?? "";
  const a = Buffer.from(input);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

8. バージョニング戦略 3 種類の比較

8.1 URL パス方式(最も普及)

GET /v1/users
GET /v2/users

メリット: ブラウザ・cURL・LB ですぐ分かる、ルーティングが単純
デメリット: REST 原理主義者は嫌う(同じリソースに異なる URI)
採用例: Stripe / Twilio / Twitter v2

8.2 ヘッダ方式(Accept-Version)

GET /users
Accept-Version: v2

メリット: URI は不変、コンテンツネゴシエーションの延長
デメリット: ブラウザでの確認が手間、CDN キャッシュキーに含める手当てが必要

8.3 Media Type 方式(本格 REST)

GET /users
Accept: application/vnd.example.user.v2+json

メリット: HTTP の仕組みに最も忠実、リソースごとに版を切れる
デメリット: 学習コスト・ツール対応がやや弱い
採用例: GitHub API(かつての主流)

8.4 バージョン分岐ミドルウェア

// src/middlewares/version.ts
import type { RequestHandler } from "express";
export const negotiateVersion: RequestHandler = (req, _res, next) => {
  // 優先度: URLパス > Accept-Version > Accept(media type) > default
  const fromHeader = req.header("accept-version");
  const fromAccept = /vnd.example.user.v(d+)/.exec(req.header("accept") ?? "");
  (req as unknown as { apiVersion: string }).apiVersion =
    fromHeader ?? fromAccept?.[1] ?? "1";
  next();
};

8.5 deprecation を伝える

// 廃止予定の v1 では Deprecation / Sunset ヘッダで予告(RFC 8594 / draft-deprecation-header)
app.use("/v1", (_req, res, next) => {
  res.set("Deprecation", "true");
  res.set("Sunset", "Wed, 31 Dec 2026 23:59:59 GMT");
  res.set("Link", '<https://api.example.com/v2/users>; rel="successor-version"');
  next();
});

9. ページネーション:Offset と Cursor を両対応

9.1 Offset 方式(実装が単純、大規模に弱い)

GET /v1/users?limit=20&offset=40

レスポンス:
{
  "data": [...],
  "meta": { "total": 1234, "limit": 20, "offset": 40 }
}

9.2 Cursor 方式(大規模で堅牢、推奨)

GET /v1/users?limit=20&cursor=eyJpZCI6IjEyMyJ9

レスポンス:
{
  "data": [...],
  "links": {
    "next": "/v1/users?limit=20&cursor=eyJpZCI6IjE0MyJ9",
    "self": "/v1/users?limit=20&cursor=eyJpZCI6IjEyMyJ9"
  }
}

9.3 Cursor の実装(base64url で id をエンコード)

// src/middlewares/pagination.ts
export const encodeCursor = (obj: Record<string, unknown>) =>
  Buffer.from(JSON.stringify(obj)).toString("base64url");

export const decodeCursor = <T>(s: string): T =>
  JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as T;

// 利用例
import { Router } from "express";
import { z } from "zod";

export const usersCursorRouter = Router();
const Q = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  cursor: z.string().optional(),
});

usersCursorRouter.get("/", (req, res) => {
  const { limit, cursor } = Q.parse(req.query);
  const afterId = cursor ? decodeCursor<{ id: string }>(cursor).id : null;
  // DB: WHERE id > afterId ORDER BY id LIMIT (limit+1)
  const items = fetchUsersAfter(afterId, limit + 1);
  const hasNext = items.length > limit;
  const page = hasNext ? items.slice(0, limit) : items;
  res.json({
    data: page,
    links: hasNext
      ? { next: `/v1/users?limit=${limit}&cursor=${encodeCursor({ id: page.at(-1)!.id })}` }
      : {},
  });
});

declare function fetchUsersAfter(_after: string | null, _limit: number): { id: string }[];

9.4 Link ヘッダ方式(GitHub 互換)

// RFC 5988 / 8288 Link ヘッダ
res.set(
  "Link",
  [
    `<${base}?cursor=${nextCursor}>; rel="next"`,
    `<${base}?cursor=${firstCursor}>; rel="first"`,
  ].join(", ")
);

10. フィルタ・ソート・フィールド選択・スパースフィールド

10.1 フィルタ(query parameter)

# 同名キーは AND、カンマ区切りは OR
GET /v1/orders?status=paid,refunded&userId=123

# 範囲は接尾辞で表現
GET /v1/orders?createdAt[gte]=2026-01-01&createdAt[lt]=2026-02-01

# 全文検索は q=
GET /v1/orders?q=keyword

10.2 ソート(sort=field, sort=-field)

GET /v1/users?sort=-createdAt,name

意味:
- createdAt 降順(先頭の "-")
- 同順位は name 昇順

10.3 フィールド選択(?fields=)

// クライアントが欲しいフィールドだけ返す(帯域削減)
import type { RequestHandler } from "express";

export const pickFields: RequestHandler = (req, res, next) => {
  const fields = String(req.query.fields ?? "")
    .split(",")
    .filter(Boolean);
  if (fields.length === 0) return next();
  const origJson = res.json.bind(res);
  res.json = (body: unknown) => {
    if (body && typeof body === "object" && "data" in body) {
      const b = body as { data: unknown };
      b.data = Array.isArray(b.data)
        ? b.data.map((d) => pick(d, fields))
        : pick(b.data, fields);
    }
    return origJson(body);
  };
  next();
};

const pick = (obj: unknown, fields: string[]) => {
  if (!obj || typeof obj !== "object") return obj;
  const out: Record<string, unknown> = {};
  for (const f of fields) out[f] = (obj as Record<string, unknown>)[f];
  return out;
};

10.4 関連の Eager Load(?include=)

GET /v1/orders/123?include=user,items
→ items / user を埋め込んで N+1 を回避

11. キャッシュ:ETag / Last-Modified / Cache-Control

11.1 Cache-Control の基本

Cache-Control: public, max-age=60, stale-while-revalidate=30
Cache-Control: private, no-cache
Cache-Control: no-store

11.2 ETag の実装(条件付き GET → 304)

// src/middlewares/etag.ts
import { createHash } from "node:crypto";
import type { RequestHandler } from "express";

export const etag: RequestHandler = (req, res, next) => {
  const origJson = res.json.bind(res);
  res.json = (body: unknown) => {
    const json = JSON.stringify(body);
    const tag = `"${createHash("sha1").update(json).digest("base64url")}"`;
    res.set("ETag", tag);
    if (req.header("if-none-match") === tag) {
      return res.status(304).send(); // 帯域ゼロで返す
    }
    return origJson(body);
  };
  next();
};

11.3 Last-Modified の実装

app.get("/v1/users/:id", (req, res, next) => {
  const user = userRepo.get(req.params.id);
  if (!user) return next();
  const lastModified = new Date(user.updatedAt).toUTCString();
  res.set("Last-Modified", lastModified);
  const ims = req.header("if-modified-since");
  if (ims && new Date(ims) >= new Date(user.updatedAt)) {
    return res.status(304).send();
  }
  res.json({ data: user });
});

11.4 楽観的ロック(If-Match)

// 書き込み時の競合検出:クライアントが ETag を覚えておき、If-Match で送る
app.patch("/v1/users/:id", (req, res, next) => {
  const user = userRepo.get(req.params.id);
  if (!user) return next();
  const currentTag = `"${createHash("sha1").update(JSON.stringify(user)).digest("base64url")}"`;
  if (req.header("if-match") && req.header("if-match") !== currentTag) {
    return next(new ProblemError(409, "Conflict", "Resource has been modified"));
  }
  // ...update
});
import { createHash } from "node:crypto";
import { ProblemError } from "../lib/problem.js";

12. レート制限と圧縮

12.1 express-rate-limit(IP / API Key 別)

import { rateLimit } from "express-rate-limit";

export const apiLimiter = rateLimit({
  windowMs: 60_000,           // 1 分
  limit: 60,                  // 60 req/min
  standardHeaders: "draft-7", // RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset
  legacyHeaders: false,
  keyGenerator: (req) => req.header("x-api-key") ?? req.ip!,
  handler: (_req, res) => {
    res.set("Retry-After", "60");
    res.status(429).type("application/problem+json").json({
      type: "https://example.com/errors/429",
      title: "Too Many Requests",
      status: 429,
    });
  },
});

12.2 圧縮(gzip / brotli)

import compression from "compression";

app.use(compression({
  threshold: 1024,           // 1KB 未満は圧縮しない
  filter: (req, res) => {
    if (req.headers["x-no-compression"]) return false;
    return compression.filter(req, res);
  },
}));

12.3 ヘッダで返す情報

RateLimit-Limit: 60
RateLimit-Remaining: 42
RateLimit-Reset: 38
Retry-After: 38
Content-Encoding: br
Vary: Accept-Encoding, Authorization

13. CORS と Preflight

13.1 単純リクエストとプリフライト

単純リクエスト:GET/POST/HEAD かつ Content-Type が text/plain, application/x-www-form-urlencoded, multipart/form-data
プリフライト:上記以外(JSON POSTやカスタムヘッダ付き)→ ブラウザが OPTIONS で許可確認

13.2 cors ミドルウェアの本番設定

import cors from "cors";

const allowed = ["https://app.example.com", "https://admin.example.com"];

app.use(cors({
  origin: (origin, cb) => {
    if (!origin) return cb(null, true);            // 同一オリジン / curl
    if (allowed.includes(origin)) return cb(null, true);
    return cb(new Error("CORS"));
  },
  credentials: true,
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization", "X-API-Key", "If-Match"],
  exposedHeaders: ["ETag", "Location", "X-Total-Count", "RateLimit-Remaining"],
  maxAge: 86400, // プリフライトキャッシュ 24h
}));

14. ファイルアップロード(multipart / 署名付き URL)

14.1 multipart 直接アップロード(小さいファイル向け)

import multer from "multer";
import { z } from "zod";

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
  fileFilter: (_req, file, cb) => {
    const ok = ["image/png", "image/jpeg", "image/webp"].includes(file.mimetype);
    cb(ok ? null : new Error("Unsupported media type"), ok);
  },
});

app.post("/v1/users/:id/avatar", upload.single("file"), (req, res, next) => {
  if (!req.file) return next(new ProblemError(400, "Bad Request", "file is required"));
  // S3 / GCS にアップロード処理…
  res.status(201).location(`/v1/users/${req.params.id}/avatar`).json({
    data: { url: `https://cdn.example.com/avatars/${req.params.id}.png` },
  });
});
import { ProblemError } from "../lib/problem.js";

14.2 大きいファイル:署名付き URL(推奨)

// 1. クライアントは POST /v1/uploads で署名付きURLを取得
// 2. クライアントは PUT で S3 / GCS 等に直接アップロード
// 3. 完了通知をサーバーへ POST /v1/uploads/:id/complete

app.post("/v1/uploads", async (req, res) => {
  const id = crypto.randomUUID();
  const { url, fields } = await createPresignedPut({
    bucket: "uploads",
    key: `tmp/${id}`,
    contentType: "application/octet-stream",
    expiresIn: 600,
  });
  res.status(201).json({ data: { id, uploadUrl: url, fields } });
});

declare function createPresignedPut(_args: {
  bucket: string; key: string; contentType: string; expiresIn: number;
}): Promise<{ url: string; fields: Record<string, string> }>;

15. HATEOAS と JSON:API / JSON-LD

15.1 HATEOAS とは(Level 3 の核心)

HATEOAS = Hypermedia As The Engine Of Application State。レスポンスに「次に行ける場所」のリンクを埋め込むことで、クライアントが URL をハードコードせず、状態遷移をたどれるようになります。実用では学習コストの割に恩恵が出ないことが多く、パブリック API や決済 API・公的 API で採用が一般的です。

15.2 単純な HATEOAS 例

{
  "data": {
    "id": "u_123",
    "name": "taro",
    "status": "active"
  },
  "_links": {
    "self":    { "href": "/v1/users/u_123" },
    "orders":  { "href": "/v1/users/u_123/orders" },
    "suspend": { "href": "/v1/users/u_123:suspend", "method": "POST" }
  }
}

15.3 JSON:API 仕様(構造を強制した REST)

{
  "data": {
    "type": "users",
    "id": "u_123",
    "attributes": { "name": "taro", "email": "t@example.com" },
    "relationships": {
      "orders": { "links": { "related": "/v1/users/u_123/orders" } }
    },
    "links": { "self": "/v1/users/u_123" }
  },
  "included": [],
  "meta": { "total": 1 }
}

15.4 JSON-LD(構造化データ・SEO 連動)

{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "https://api.example.com/v1/products/p_42",
  "name": "Wireless Earbuds",
  "offers": { "@type": "Offer", "price": "9800", "priceCurrency": "JPY" }
}

16. OpenAPI 3.1 を真実の単一情報源(SSoT)に

16.1 なぜ OpenAPI か

OpenAPI 3.1 は JSON Schema 2020-12 と完全互換で、Swagger UI / Redoc / Prism / Postman / Bruno / SDK 自動生成のすべてが繋がります。先にスキーマ、後でコード(Design First)が現代の主流です。

16.2 openapi.yaml(最小サンプル)

openapi: 3.1.0
info:
  title: Users API
  version: 1.0.0
  description: REST API design sample
servers:
  - url: https://api.example.com/v1
paths:
  /users:
    get:
      summary: List users
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - in: query
          name: offset
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/User" }
                  meta:
                    $ref: "#/components/schemas/Meta"
    post:
      summary: Create a user
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UserCreate" }
      responses:
        "201":
          description: Created
          headers:
            Location:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/User" }
        "422":
          $ref: "#/components/responses/ValidationError"
components:
  schemas:
    User:
      type: object
      required: [id, email, name, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        name: { type: string, minLength: 1, maxLength: 80 }
        age: { type: integer, minimum: 0, maximum: 150 }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    UserCreate:
      type: object
      required: [email, name]
      properties:
        email: { type: string, format: email }
        name: { type: string, minLength: 1, maxLength: 80 }
        age: { type: integer, minimum: 0, maximum: 150 }
    Meta:
      type: object
      properties:
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
    Problem:
      type: object
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              path: { type: string }
              message: { type: string }
  responses:
    ValidationError:
      description: Validation Failed
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
security:
  - bearerAuth: []

16.3 Swagger UI を組み込む

import swaggerUi from "swagger-ui-express";
import fs from "node:fs";
import yaml from "yaml";

const spec = yaml.parse(fs.readFileSync("openapi.yaml", "utf8"));
app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec));
// http://localhost:3000/docs で対話的ドキュメント

16.4 Redoc(美しい静的ドキュメント)

<!-- redoc.html -->
<!doctype html>
<html>
  <head><meta charset="utf-8" /><title>Users API</title></head>
  <body>
    <redoc spec-url="./openapi.yaml"></redoc>
    <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
  </body>
</html>

16.5 Prism Mock(契約先行でフロントが先に動ける)

npm i -D @stoplight/prism-cli
npx prism mock openapi.yaml --port 4010
# → http://127.0.0.1:4010/users にダミー応答

16.6 Redocly で lint / bundle

npm i -D @redocly/cli
npx redocly lint openapi.yaml
npx redocly bundle openapi.yaml -o openapi.bundled.yaml

17. テスト:Vitest + Supertest と契約テスト

17.1 vitest.config.ts

import { defineConfig } from "vitest/config";
export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    coverage: { provider: "v8", reporter: ["text", "html"], lines: 80 },
  },
});

17.2 Supertest による E2E テスト

// tests/users.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import request from "supertest";
import { createApp } from "../src/app.js";

const app = createApp();

describe("POST /v1/users", () => {
  it("201 で作成し Location ヘッダを返す", async () => {
    const res = await request(app)
      .post("/v1/users")
      .send({ email: "t@example.com", name: "taro" });
    expect(res.status).toBe(201);
    expect(res.header.location).toMatch(/^/v1/users//);
    expect(res.body.data.id).toEqual(expect.any(String));
  });

  it("422 で Problem を返す(invalid email)", async () => {
    const res = await request(app)
      .post("/v1/users")
      .send({ email: "bad", name: "taro" });
    expect(res.status).toBe(422);
    expect(res.header["content-type"]).toContain("application/problem+json");
    expect(res.body.errors[0].path).toBe("email");
  });
});

describe("GET /v1/users", () => {
  it("X-Total-Count を返す", async () => {
    const res = await request(app).get("/v1/users?limit=10");
    expect(res.status).toBe(200);
    expect(res.header["x-total-count"]).toBeDefined();
  });
});

17.3 OpenAPI 契約テスト(jest-openapi 風)

import { expect } from "vitest";
import OpenAPIValidator from "openapi-schema-validator";
import yaml from "yaml";
import fs from "node:fs";

const spec = yaml.parse(fs.readFileSync("openapi.yaml", "utf8"));
// レスポンス body が OpenAPI スキーマに準拠しているかチェックする
// ※ 実運用は jest-openapi / supertest-openapi-response-validator を推奨

18. CI/CD パイプライン

18.1 GitHub Actions(lint + test + openapi 検証)

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run lint
      - run: npx redocly lint openapi.yaml
      - run: npm test -- --coverage
      - name: Build
        run: npm run build

18.2 Spectral によるカスタム lint

# .spectral.yaml
extends: [[spectral:oas, all]]
rules:
  operation-tag-defined: error
  operation-operationId: error
  no-$ref-siblings: error
  contact-properties: warn

19. OWASP API Security Top 10(2023)対策

19.1 API1 Broken Object Level Authorization (BOLA)

// 自分以外のリソースを取れないようにする
usersRouter.get("/:id", bearerAuth, (req, res, next) => {
  if (req.user!.id !== req.params.id && !req.user!.scopes.includes("admin")) {
    return next(new ProblemError(403, "Forbidden"));
  }
  // ...
});
import { bearerAuth } from "../middlewares/auth.js";
import { ProblemError } from "../lib/problem.js";

19.2 API3 Broken Object Property Level Authorization

// PATCH で role や isAdmin を勝手に書き換えられないようにする
const SafeUserUpdate = UserUpdateSchema.pick({ name: true, age: true });
// role などはこの一覧に入れない

19.3 API4 Unrestricted Resource Consumption

app.use(express.json({ limit: "1mb" }));        // ボディサイズ制限
app.use(rateLimit({ windowMs: 60_000, limit: 60 }));
// クエリの limit には必ず上限(.max(100))を付ける

19.4 API7 SSRF(Server-Side Request Forgery)対策

// 外部URLを受け取ってサーバーから取得する API では、ホスト名を allowlist で絞る
const ALLOW = new Set(["images.example.com", "cdn.example.com"]);
import { URL } from "node:url";
function safeFetch(input: string) {
  const u = new URL(input);
  if (!ALLOW.has(u.hostname)) throw new ProblemError(400, "Bad URL");
  // プライベートIP帯(10/8, 192.168/16, 127/8, 169.254/16, fc00::/7)も拒否
  return fetch(u);
}

19.5 API8 Security Misconfiguration → helmet 必須

import helmet from "helmet";
app.use(helmet({
  contentSecurityPolicy: false, // API ならオフでも可
  crossOriginResourcePolicy: { policy: "cross-origin" },
  referrerPolicy: { policy: "no-referrer" },
}));

19.6 SQL Injection(プレースホルダ必須)

// NG:文字列連結
// const sql = `SELECT * FROM users WHERE email = '${email}'`;

// OK:パラメータ化クエリ
import { Pool } from "pg";
const pool = new Pool();
const { rows } = await pool.query("SELECT * FROM users WHERE email = $1", [email]);

19.7 CSRF(Cookie ベース API のみ)

Bearer トークン方式(Authorization ヘッダ)であれば、CSRF は基本的に影響を受けません。Cookie 認証を使う場合のみ SameSite=Lax 設定と CSRF トークン発行が必要です。

// Bearer 方式なら不要。Cookie 方式の場合のみ:
import { doubleCsrf } from "csrf-csrf";

const { doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET ?? "dev",
  cookieName: "__Host-csrf",
  cookieOptions: { secure: true, sameSite: "lax", path: "/" },
});
app.use(doubleCsrfProtection);

20. パフォーマンス:HTTP/2・HTTP/3・ストリーミング

20.1 HTTP/2 を有効化(Node.js 標準モジュール)

import http2 from "node:http2";
import fs from "node:fs";

const server = http2.createSecureServer({
  key: fs.readFileSync("./key.pem"),
  cert: fs.readFileSync("./cert.pem"),
});
server.on("stream", (stream, headers) => {
  stream.respond({ "content-type": "application/json", ":status": 200 });
  stream.end(JSON.stringify({ ok: true }));
});
server.listen(8443);

20.2 ストリーミング応答(大容量 CSV など)

app.get("/v1/users.csv", (_req, res) => {
  res.set("Content-Type", "text/csv");
  res.set("Content-Disposition", 'attachment; filename="users.csv"');
  res.write("id,name,emailn");
  for (const u of userRepo.list({ limit: 1_000_000, offset: 0 }).items) {
    res.write(`${u.id},${u.name},${u.email}n`);
  }
  res.end();
});

20.3 SSE(Server-Sent Events)

app.get("/v1/events", (req, res) => {
  res.set({
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });
  const t = setInterval(() => {
    res.write(`data: ${JSON.stringify({ ts: Date.now() })}nn`);
  }, 1_000);
  req.on("close", () => clearInterval(t));
});

21. REST vs tRPC vs GraphQL の選定基準

21.1 比較表

観点              REST                  tRPC                  GraphQL
契約定義          OpenAPI(YAML/JSON)   TS型のみ              SDL(.graphql)
言語独立性        ◎                    △(TS前提)          ◎
クライアントSDK   自動生成可             自動生成              codegen / urql / Apollo
オーバーフェッチ  あり(fields, include) なし                 なし(必要なフィールドだけ)
キャッシュ        HTTPキャッシュ最強     なし(独自必要)      Apollo Client / Relay
ファイルアップ    ◎(multipart)         △                    △(multipart拡張)
リアルタイム      SSE / WebSocket       Subscriptions         Subscriptions
学習コスト        低                    低                    中
公開API向き       ◎                    ✗                     △
社内BFF向き       ◯                    ◎                     ◯

21.2 選定フローチャート

公開API or サードパーティ連携? → REST
TS のフロント+バックを 1 人/小チームで作る? → tRPC
グラフ的なデータ・複数クライアント・大規模? → GraphQL
迷ったら → REST(40 年のエコシステムを取りに行く)

22. Postman / Bruno コレクション

22.1 Bruno(Git 管理可能・OSS)

# users.bru
meta {
  name: List Users
  type: http
  seq: 1
}
get {
  url: {{baseUrl}}/v1/users?limit=10
}
headers {
  Authorization: Bearer {{token}}
}
tests {
  test("200 OK", () => {
    expect(res.status).toBe(200);
  });
}

22.2 Postman 環境変数

{
  "name": "local",
  "values": [
    { "key": "baseUrl", "value": "http://localhost:3000" },
    { "key": "token", "value": "...jwt..." }
  ]
}

23. ロギング・トレーシング・監視

23.1 pino による構造化ログ

import pino from "pino";
import pinoHttp from "pino-http";

export const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });

app.use(pinoHttp({
  logger,
  customLogLevel: (_req, res, err) => {
    if (err || res.statusCode >= 500) return "error";
    if (res.statusCode >= 400) return "warn";
    return "info";
  },
  redact: ["req.headers.authorization", "req.headers.cookie"],
}));

23.2 OpenTelemetry(Trace ID をレスポンスに)

import { trace } from "@opentelemetry/api";

app.use((_req, res, next) => {
  const span = trace.getActiveSpan();
  const traceId = span?.spanContext().traceId;
  if (traceId) res.set("X-Trace-Id", traceId);
  next();
});

24. 設計チェックリスト(納品前の最終確認)

REST API 設計 完成度チェック 30 項目

  1. リソースは複数形・ハイフン・小文字
  2. URL に動詞が紛れていない(コントローラーリソースは例外)
  3. GET / PUT / DELETE が冪等
  4. POST 成功は 201 + Location ヘッダ
  5. DELETE 成功は 204 No Content
  6. 401 と 403 を取り違えていない
  7. 400 と 422 を取り違えていない
  8. 405 で Allow ヘッダを返している
  9. エラーは RFC 7807 problem+json
  10. バージョニング戦略を 1 つに統一
  11. ページネーション(Offset または Cursor)
  12. ソート・フィルタ・フィールド選択のクエリパラメータ命名一貫
  13. ETag / Last-Modified による 304 対応
  14. Cache-Control を明示
  15. レート制限(RateLimit-* ヘッダ)
  16. 圧縮(gzip / brotli)
  17. CORS のオリジン allowlist 化
  18. helmet などセキュリティヘッダ
  19. JSON ボディサイズ制限
  20. Bearer / API Key の方式選定
  21. SQL はパラメータ化クエリ
  22. SSRF 防止(allowlist + プライベートIP拒否)
  23. BOLA / BOPLA 防止(認可ロジック)
  24. OpenAPI 3.1 で全 endpoint を記述
  25. Swagger UI / Redoc で公開
  26. Vitest + Supertest で 80% 以上カバー
  27. 契約テスト(レスポンス schema 検証)
  28. CI で lint + redocly + test を自動化
  29. 構造化ログ・Trace ID
  30. Sunset / Deprecation ヘッダで廃止予告

25. まとめ:REST 設計の 7 原則

本記事で示した REST API 設計の核心は以下 7 つに凝縮されます。

  1. リソース(名詞)で世界をモデル化し、動詞は HTTP に任せる
  2. ステータスコードを正しく使い分け、エラーは RFC 7807 に統一
  3. バージョニングは URL パス方式から始め、Deprecation / Sunset で廃止予告
  4. 大規模ならページネーションは Cursor 方式、フィルタ・ソート・フィールド選択を一貫
  5. ETag / Last-Modified / Cache-Control で HTTP キャッシュの恩恵を最大化
  6. OpenAPI 3.1 を真実の単一情報源に。Swagger UI / Redoc / Prism / Vitest を連結
  7. OWASP API Top 10 を当てて、helmet / rate-limit / CORS / SSRF / BOLA を塞ぐ

REST は「古い」のではなく「成熟」しています。HTTP のあらゆる機能を素直に使うこのスタイルは、CDN・LB・プロキシ・ブラウザのすべてが味方になり、長期保守に最も強い設計です。tRPC や GraphQL と棲み分けながら、公開 API・SDK 配布・サードパーティ連携の現場では引き続き第一選択であり続けるでしょう。本記事のコードと 30 項目チェックリストを土台に、迷いのない REST API を設計してください。

次の一歩(関連記事)

  • Express 5 完全実践ガイド(本記事の Express 部分の詳細)
  • Hono 完全実践ガイド(Edge ランタイムで同じ REST を書く)
  • Zod 完全実践ガイド(本記事のバリデーション層を深掘り)
  • Prisma 完全実践ガイド(本記事の永続化層を実装)
REST API 設計を体系的に学ぶには?

本記事のコード量は意図的に多めですが、現場では「設計レビュー」「セキュリティ判断」「DB と連動した認可設計」など、コードの外側のスキルが鍵になります。独学だけでなく、メンター付きで実プロジェクトをこなせるプログラミングスクール(テックアカデミー / 侍エンジニア / DMM WEBCAMP)や、現役エンジニアとして案件を進めながら学べるレバテック系のフリーランス案件を活用すると、設計力の伸びが段違いです。

コメント

タイトルとURLをコピーしました