【Node.js実戦習得】第10回:REST API設計のベストプラクティスとステータスコードの厳密な運用

Node.js

1. REST API 設計の本質:リソース表現と HTTP メソッド

酒本先輩!前回までは Express のミドルウェアやエラーハンドリングを学びました。

でも、API のエンドポイント(URL)を作るとき、/api/get-cart にするか /api/cart にするか迷うことがあります。実務での正しい決め手ってあるんですか?

すごく良い視点ね!それがまさに REST(Representational State Transfer) の考え方よ。

REST API 設計の基本原則は 『URLは操作(動詞)ではなくリソース(名詞)を表し、操作は HTTP メソッドで表現する』 というルールなの。

ダメな設計(アンチパターン)と RESTful な設計の比較

  • /api/get-cart(GET) … URL に get という動詞が入っている
  • /api/create-item(POST) … URL に create という動詞が入っている
  • ⭕️ /api/cart/items(GET) … カート内の商品一覧を取得
  • ⭕️ /api/cart/items(POST) … カートに新しい商品を追加
【リソース(名詞)】 ✕ 【HTTPメソッド(動詞)】 の組み合わせで表現する!

  GET    /api/cart/items      ---> カート一覧の取得 (200 OK)
  POST   /api/cart/items      ---> 商品の新規追加 (201 Created)
  PUT    /api/cart/items/:id  ---> 指定商品の全置換 (200 OK)
  PATCH  /api/cart/items/:id  ---> 指定商品の一部更新 (200 OK)
  DELETE /api/cart/items/:id  ---> 指定商品の削除 (204 No Content)

2. HTTP ステータスコードの厳密な使い分け

REST API では、処理結果に応じた ステータスコードの厳密な使い分け が必須よ。

『エラーが起きたけどレスポンスボディにエラー内容を入れたから 200 OK を返す』なんて設計は絶対にダメ!」

 

実務で頻出する主要なステータスコードは以下の通りよ。

分類コード意味ユースケース
2xx 成功200 OKリクエスト成功GET/PUT/PATCH の成功時
201 Createdリソース作成成功POST による新規作成成功時(作成されたオブジェクトを返す)
204 No Content処理成功・返却データなしDELETE 成功時(レスポンスボディは空)
4xx クライアントエラー400 Bad Requestリクエスト不備バリデーションエラー、JSON パースエラー
401 Unauthorized未認証ログインが必要なエンドポイントへの未認証アクセス
403 Forbidden認可エラー(権限不足)ログイン済みだが対象リソースへのアクセス権がない
404 Not Foundリソース未存在存在しない URL や ID へのアクセス
5xx サーバーエラー500 Internal Server Errorサーバー内部エラーDB 障害、バグによる未補足例外

3. 実践コード:RESTful 規約に準拠した EC カート API

それじゃあ、第9回で作成した Express アプリケーションを、RESTful な規約と適切なステータスコードを適用したコードに更新しましょう!

// src/index.ts
import express, { Request, Response, NextFunction } from 'express';

const app = express();
const PORT = 3000;

app.use(express.json());

// 共通ロギングミドルウェア
app.use((req: Request, res: Response, next: NextFunction) => {
  const start = Date.now();
  const { method, url } = req;

  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`[LOG] ${new Date().toISOString()} | ${method} ${url} | Status: ${res.statusCode} | ${duration}ms`);
  });

  next();
});

// カート内商品の型定義
interface CartItem {
  id: number;
  productName: string;
  price: number;
  quantity: number;
}

// 疑似データベース(インメモリ)
let cartItems: CartItem[] = [
  { id: 1, productName: 'キーボード', price: 12000, quantity: 1 }
];
let nextId = 2;

// ==========================================
//  1. GET /api/cart/items (一覧取得 -> 200 OK)
// ==========================================
app.get('/api/cart/items', (req: Request, res: Response) => {
  res.status(200).json({
    data: cartItems,
    total: cartItems.length
  });
});

// ==========================================
//  2. POST /api/cart/items (新規追加 -> 201 Created / 400 Bad Request)
// ==========================================
app.post('/api/cart/items', (req: Request, res: Response) => {
  const { productName, price, quantity } = req.body;

  // バリデーションエラー (400 Bad Request)
  if (!productName || typeof price !== 'number' || price <= 0) {
    res.status(400).json({
      error: 'BAD_REQUEST',
      message: 'productName は必須であり、price は正の数値である必要があります。'
    });
    return;
  }

  const newItem: CartItem = {
    id: nextId++,
    productName,
    price,
    quantity: quantity ?? 1
  };

  cartItems.push(newItem);

  // 201 Created でリソース生成完了を通知
  res.status(201).json(newItem);
});

// ==========================================
//  3. PATCH /api/cart/items/:id (一部更新 -> 200 OK / 400 / 404)
// ==========================================
app.patch('/api/cart/items/:id', (req: Request, res: Response) => {
  const id = Number(req.params.id);
  if (isNaN(id)) {
    res.status(400).json({ error: 'BAD_REQUEST', message: 'IDは数値で指定してください。' });
    return;
  }

  const item = cartItems.find(i => i.id === id);
  if (!item) {
    res.status(404).json({ error: 'NOT_FOUND', message: '指定されたアイテムが見つかりません。' });
    return;
  }

  const { quantity } = req.body;
  if (typeof quantity === 'number' && quantity > 0) {
    item.quantity = quantity;
  }

  res.status(200).json(item);
});

// ==========================================
//  4. DELETE /api/cart/items/:id (削除 -> 204 No Content / 404)
// ==========================================
app.delete('/api/cart/items/:id', (req: Request, res: Response) => {
  const id = Number(req.params.id);
  if (isNaN(id)) {
    res.status(400).json({ error: 'BAD_REQUEST', message: 'IDは数値で指定してください。' });
    return;
  }

  const index = cartItems.findIndex(i => i.id === id);
  if (index === -1) {
    res.status(404).json({ error: 'NOT_FOUND', message: '指定されたアイテムが見つかりません。' });
    return;
  }

  cartItems.splice(index, 1);

  // 204 No Content: 削除成功(レスポンスボディは空)
  res.status(204).send();
});

// 404 Not Found ハンドラー
app.use((req: Request, res: Response) => {
  res.status(404).json({ error: 'NOT_FOUND', message: 'リクエストされたエンドポイントが存在しません。' });
});

// 集中エラーハンドラー (500)
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'INTERNAL_SERVER_ERROR', message: '予期せぬエラーが発生しました。' });
});

app.listen(PORT, () => {
  console.log(` サーバー起動: http://localhost:${PORT}`);
});

4. curl による動作確認手順

さっそく、ステータスコードとレスポンスボディの挙動を curl -i (ヘッダー情報表示)で確認してみます!

1. 新規追加(POST -> 201 Created)

curl -i -X POST http://localhost:3000/api/cart/items \
  -H "Content-Type: application/json" \
  -d '{"productName": "エルゴノミクスマウス", "price": 8500, "quantity": 2}'

実行結果:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{"id":2,"productName":"エルゴノミクスマウス","price":8500,"quantity":2}

2. 不正データでの追加(POST -> 400 Bad Request)

curl -i -X POST http://localhost:3000/api/cart/items \
  -H "Content-Type: application/json" \
  -d '{"productName": ""}'

実行結果:

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"error":"BAD_REQUEST","message":"productName は必須であり、price は正の数値である必要があります。"}

3. 数量の更新(PATCH -> 200 OK)

curl -i -X PATCH http://localhost:3000/api/cart/items/2 \
  -H "Content-Type: application/json" \
  -d '{"quantity": 5}'

実行結果:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"id":2,"productName":"エルゴノミクスマウス","price":8500,"quantity":5}

4. リソースの削除(DELETE -> 204 No Content)

curl -i -X DELETE http://localhost:3000/api/cart/items/2

実行結果:

HTTP/1.1 204 No Content

(※204 No Content のためレスポンスボディは返りません)

5. 削除済みのリソースへアクセス(DELETE -> 404 Not Found)

curl -i -X DELETE http://localhost:3000/api/cart/items/2

実行結果:

HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8

{"error":"NOT_FOUND","message":"指定されたアイテムが見つかりません。"}

レスポンスヘッダーのステータスコードを見るだけで、処理結果がひと目でわかりますね!

204 No Content の時はボディが空になるのも納得です!

本日のまとめ

  • REST API 原則: URL には名詞(リソース)を指定し、操作は HTTP メソッド(GET/POST/PATCH/DELETE)で表現する。
  • ステータスコードの厳密な運用:
  • 生成成功は 201 Created
  • 削除成功は 204 No Content
  • 不正リクエストは 400 Bad Request
  • 未存在リソースは 404 Not Found
  • 一貫性のあるエラーレスポンス: エラーコードとメッセージを統一された JSON 形式で返す設計がクライアント開発において重要。

次回:第11回「Zodライブラリによるスキーマバリデーションとランタイム型安全の確立」に続く

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