サイトアイコンmaita tomoya dev io

RESTful API

API/認証

RESTful API

APIとは何か

API(Application Programming Interface)は、ソフトウェア同士が「会話」するための約束事(インターフェース) のこと。

レストランに例えるとわかりやすい。

お客さん(フロントエンド)
    |
    | 「カレーライスをください」(リクエスト)
    v
ウェイター(API)
    |
    | 注文をキッチンに伝える
    v
キッチン(サーバー/データベース)
    |
    | 料理を作る
    v
ウェイター(API)
    |
    | 「お待たせしました」(レスポンス)
    v
お客さん(フロントエンド)

お客さんはキッチンの中に入れないし、料理の作り方を知る必要もない。ウェイター(API)が間に立って、決まったメニュー(エンドポイント)から注文を受け付け、結果を返してくれる。

身の回りのAPI利用例:

サービスAPI利用例
天気予報アプリ気象データAPIから天気情報を取得
SNSログインGoogle/GitHub APIでユーザー認証
地図アプリGoogle Maps APIで地図データを取得
決済システムStripe APIでクレジットカード決済
チャットボットOpenAI APIでAIの回答を取得

つまりAPIとは、「このURLにこの形式でリクエストを送ったら、こういう形式でデータが返ってきます」という約束事

RESTとは

REST(Representational State Transfer)は、2000年にRoy Fieldingが博士論文で提唱したWebサービスを設計するためのアーキテクチャスタイル(設計思想) のこと。

RESTは「プロトコル(厳密なルール)」ではなく「スタイル(設計の考え方)」であることがポイント。つまり、RESTの原則に従って設計されたAPIをRESTful APIと呼ぶ。

RESTの6つの制約

制約説明具体例
クライアント-サーバーUIとデータ処理を分離するフロントエンド(React)とバックエンド(Express)を分ける
ステートレスサーバーはリクエスト間で状態を保持しない毎回のリクエストに必要な情報を全て含める(認証トークン等)
キャッシュ可能レスポンスがキャッシュ可能かどうかを明示するCache-Controlヘッダーの活用
統一インターフェース一貫したURLとHTTPメソッドの使い方/api/usersにGETで取得、POSTで作成
レイヤードシステム中間サーバー(ロードバランサ、プロキシ等)を挟めるCDN、APIゲートウェイの利用
コードオンデマンド(任意)必要に応じてクライアントにコードを送信JavaScriptの動的ロード

最も重要なのは統一インターフェースステートレスの2つ。

ステートレスとは: セッション情報をサーバー側に保存しないということ。リクエストごとに認証情報(トークン等)を送信する必要がある。

【ステートフル(従来のセッション方式)】
リクエスト1: 「ログインします」 → サーバー:「OK、session_id=abc123を覚えておきます」
リクエスト2: 「データをください」 → サーバー:「abc123さんですね、はいどうぞ」
(サーバーがセッション状態を記憶している)

【ステートレス(REST方式)】
リクエスト1: 「ログインします」 → サーバー:「OK、トークンJWT_XYZを発行します」
リクエスト2: 「データをください。トークンはJWT_XYZです」 → サーバー:「トークン確認OK、はいどうぞ」
(毎回自己完結した情報を送る。サーバーは何も覚えていない)

RESTful APIの設計原則

リソース指向

RESTでは全てをリソース(資源) として考える。リソースとは「操作の対象となるもの」のこと。

  • ユーザー → /users
  • 投稿記事 → /posts
  • コメント → /comments
  • 商品 → /products

リソースは名詞で表現し、操作はHTTPメソッドで表現する。これがRESTの基本的な考え方。

悪い例: /getUsers, /createUser, /deleteUser(動詞がURLに含まれている)
良い例: GET /users, POST /users, DELETE /users/:id(動詞はHTTPメソッドで表現)

HTTPメソッド

HTTPメソッドは「リソースに対して何をするか」を表現する。

主要メソッド一覧

メソッド意味CRUD操作べき等性安全性リクエストボディ
GETリソースの取得Readありありなし
POSTリソースの作成Createなしなしあり
PUTリソースの全体更新Update(全体)ありなしあり
PATCHリソースの部分更新Update(部分)なしなしあり
DELETEリソースの削除Deleteありなしなし

べき等性(idempotent) とは、「何回実行しても結果が同じになる」性質のこと。

  • GET /users/1 → 何回呼んでも同じユーザーが返る(べき等)
  • DELETE /users/1 → 1回目は削除成功、2回目以降は404だが、最終状態は同じ(べき等)
  • POST /users → 呼ぶたびに新しいユーザーが作られる(べき等ではない)

安全性(safe) とは、「サーバーの状態を変更しない」性質のこと。GETは安全(データを取得するだけ)、POSTは安全ではない(データを変更する)。

各メソッドの使い分け

GET    /api/users        → ユーザー一覧を取得
GET    /api/users/123    → ID:123のユーザーを取得
POST   /api/users        → 新しいユーザーを作成
PUT    /api/users/123    → ID:123のユーザーを全体更新
PATCH  /api/users/123    → ID:123のユーザーを部分更新
DELETE /api/users/123    → ID:123のユーザーを削除

PUTとPATCHの違い

// 現在のユーザーデータ
// { id: 1, name: "太郎", email: "taro@example.com", age: 25 }

// PUT /api/users/1(全体更新 - 全フィールドを送信する必要がある)
// リクエストボディ:
{ "name": "太郎", "email": "newtaro@example.com", "age": 25 }
// → 全フィールドを指定しないと、省略したフィールドが消える可能性がある

// PATCH /api/users/1(部分更新 - 変更したいフィールドだけ送ればOK)
// リクエストボディ:
{ "email": "newtaro@example.com" }
// → emailだけが更新され、他のフィールドはそのまま

HTTPステータスコード

ステータスコードは「リクエストの結果」をクライアントに伝えるための3桁の数字。

主要ステータスコード一覧

コード名前意味使用場面
2xx: 成功
200OK成功GETの成功、PUT/PATCHの成功
201Createdリソース作成成功POSTの成功
204No Content成功だがレスポンスボディなしDELETEの成功
3xx: リダイレクト
301Moved Permanently恒久的な移転URLの恒久変更
302Found一時的な移転一時的なリダイレクト
304Not Modified変更なし(キャッシュ利用可)キャッシュが有効な場合
4xx: クライアントエラー
400Bad Requestリクエストが不正バリデーションエラー
401Unauthorized認証が必要トークンなし/無効
403Forbiddenアクセス権限なし権限不足
404Not Foundリソースが見つからない存在しないURL
405Method Not Allowed許可されていないメソッドGET専用にPOSTした場合
409Conflict競合重複登録など
422Unprocessable Entity処理不可データの意味的な誤り
429Too Many Requestsリクエスト過多レート制限に到達
5xx: サーバーエラー
500Internal Server Errorサーバー内部エラー予期しないエラー
502Bad Gatewayゲートウェイエラーバックエンドサーバーの障害
503Service Unavailableサービス利用不可メンテナンス中
504Gateway Timeoutタイムアウトバックエンドの応答遅延

覚え方:

  • 2xx = 「うまくいった」
  • 3xx = 「あっちを見て」
  • 4xx = 「あなた(クライアント)が悪い」
  • 5xx = 「私(サーバー)が悪い」

URI設計のベストプラクティス

基本ルール

ルール良い例悪い例理由
名詞を使う/users/getUsersHTTPメソッドが動詞の役割を担う
複数形を使う/users/userリソースのコレクションを表す
小文字を使う/user-profiles/UserProfilesURLは大文字小文字を区別する
ハイフン区切り/user-profiles/user_profilesURLの慣例
階層構造/users/123/posts/getUserPosts?id=123リソースの親子関係を表現
バージョニング/api/v1/users/api/usersAPIの破壊的変更に対応
末尾スラッシュなし/users/users/統一性のため

階層構造の例

/api/v1/users                    → 全ユーザー一覧
/api/v1/users/123                → ID:123のユーザー
/api/v1/users/123/posts          → ユーザー123の投稿一覧
/api/v1/users/123/posts/456      → ユーザー123の投稿456
/api/v1/users/123/posts/456/comments  → 投稿456のコメント一覧

ただし、階層は3段階までにとどめるのが推奨。深すぎるネストは可読性が下がる。

悪い例: /api/v1/users/123/posts/456/comments/789/likes(深すぎる)
良い例: /api/v1/comments/789/likes(直接アクセスできるようにする)

リクエストとレスポンスの構造

HTTPリクエストの構造

POST /api/v1/users HTTP/1.1          ← リクエストライン(メソッド、URL、プロトコル)
Host: api.example.com                ← ヘッダー
Content-Type: application/json       ← ヘッダー(ボディの形式を指定)
Authorization: Bearer eyJhbGci...    ← ヘッダー(認証トークン)
Accept: application/json             ← ヘッダー(期待するレスポンス形式)
                                     ← 空行
{                                    ← ボディ(リクエストデータ)
  "name": "太郎",
  "email": "taro@example.com"
}

HTTPレスポンスの構造

HTTP/1.1 201 Created                 ← ステータスライン
Content-Type: application/json       ← ヘッダー
X-RateLimit-Remaining: 99           ← ヘッダー(カスタム)
                                     ← 空行
{                                    ← ボディ(レスポンスデータ)
  "id": 1,
  "name": "太郎",
  "email": "taro@example.com",
  "createdAt": "2026-03-28T10:00:00Z"
}

よく使うヘッダー

ヘッダー方向説明
Content-Type両方ボディのデータ形式(application/jsonが一般的)
Authorizationリクエスト認証情報(Bearer <トークン>
Acceptリクエストクライアントが受け入れ可能なデータ形式
Cache-Controlレスポンスキャッシュの制御
X-RateLimit-Limitレスポンスレート制限の上限
X-RateLimit-Remainingレスポンス残りのリクエスト数

JSONフォーマット

RESTful APIのデータ交換で最も一般的に使われるフォーマット。

JSONの基本構造

{
  "string": "文字列はダブルクォートで囲む",
  "number": 42,
  "float": 3.14,
  "boolean": true,
  "null": null,
  "array": [1, 2, 3],
  "object": {
    "nested": "オブジェクトの中にオブジェクト"
  }
}

APIレスポンスのよくある形式

// 単一リソース
{
  "id": 1,
  "name": "太郎",
  "email": "taro@example.com",
  "createdAt": "2026-03-28T10:00:00Z"
}

// リソースのリスト(ページネーション付き)
{
  "data": [
    { "id": 1, "name": "太郎" },
    { "id": 2, "name": "花子" }
  ],
  "pagination": {
    "page": 1,
    "perPage": 20,
    "totalPages": 5,
    "totalItems": 100
  }
}

// エラーレスポンス
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "入力値が不正です",
    "details": [
      { "field": "email", "message": "有効なメールアドレスを入力してください" },
      { "field": "name", "message": "名前は必須項目です" }
    ]
  }
}

JSONの注意点

注意点説明
キーはダブルクォート必須{ name: "太郎" } はNG。{ "name": "太郎" } が正しい
末尾カンマ不可{ "a": 1, } はNG。最後の要素の後にカンマを付けない
コメント不可JSONにコメントは書けない
日付型なし文字列としてISO 8601形式(2026-03-28T10:00:00Z)を使う
undefinedなしnullを使う

Express.jsでのAPI実装

CRUD全操作のコード例

const express = require('express')
const app = express()

app.use(express.json())

// 仮のデータベース(実際にはDBを使う)
let users = [
  { id: 1, name: '太郎', email: 'taro@example.com', age: 25 },
  { id: 2, name: '花子', email: 'hanako@example.com', age: 30 },
  { id: 3, name: '次郎', email: 'jiro@example.com', age: 22 },
]
let nextId = 4

// ========================================
// CREATE - ユーザーの作成
// POST /api/users
// ========================================
app.post('/api/users', (req, res) => {
  const { name, email, age } = req.body

  // バリデーション
  if (!name || !email) {
    return res.status(400).json({
      error: {
        message: '名前とメールアドレスは必須です',
        details: [
          ...(!name ? [{ field: 'name', message: '名前は必須です' }] : []),
          ...(!email ? [{ field: 'email', message: 'メールアドレスは必須です' }] : []),
        ],
      },
    })
  }

  // メールアドレスの重複チェック
  if (users.find((u) => u.email === email)) {
    return res.status(409).json({
      error: { message: 'このメールアドレスは既に使用されています' },
    })
  }

  const newUser = { id: nextId++, name, email, age: age || null }
  users.push(newUser)

  // 201 Created + 作成されたリソースを返す
  res.status(201).json(newUser)
})

// ========================================
// READ - ユーザー一覧の取得
// GET /api/users
// GET /api/users?page=1&limit=10&sort=name&order=asc
// ========================================
app.get('/api/users', (req, res) => {
  let result = [...users]

  // フィルタリング(クエリパラメータ)
  if (req.query.name) {
    result = result.filter((u) => u.name.toLowerCase().includes(req.query.name.toLowerCase()))
  }

  // ソート
  const sortField = req.query.sort || 'id'
  const sortOrder = req.query.order === 'desc' ? -1 : 1
  result.sort((a, b) => {
    if (a[sortField] < b[sortField]) return -1 * sortOrder
    if (a[sortField] > b[sortField]) return 1 * sortOrder
    return 0
  })

  // ページネーション
  const page = parseInt(req.query.page) || 1
  const limit = parseInt(req.query.limit) || 10
  const startIndex = (page - 1) * limit
  const endIndex = startIndex + limit
  const paginatedResult = result.slice(startIndex, endIndex)

  res.json({
    data: paginatedResult,
    pagination: {
      page,
      limit,
      totalItems: result.length,
      totalPages: Math.ceil(result.length / limit),
    },
  })
})

// ========================================
// READ - 個別ユーザーの取得
// GET /api/users/:id
// ========================================
app.get('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id)
  const user = users.find((u) => u.id === id)

  if (!user) {
    return res.status(404).json({
      error: { message: `ID: ${id} のユーザーが見つかりません` },
    })
  }

  res.json(user)
})

// ========================================
// UPDATE - ユーザーの全体更新
// PUT /api/users/:id
// ========================================
app.put('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id)
  const index = users.findIndex((u) => u.id === id)

  if (index === -1) {
    return res.status(404).json({
      error: { message: `ID: ${id} のユーザーが見つかりません` },
    })
  }

  const { name, email, age } = req.body
  if (!name || !email) {
    return res.status(400).json({
      error: { message: 'PUTリクエストでは全フィールドが必須です' },
    })
  }

  users[index] = { id, name, email, age: age || null }
  res.json(users[index])
})

// ========================================
// UPDATE - ユーザーの部分更新
// PATCH /api/users/:id
// ========================================
app.patch('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id)
  const index = users.findIndex((u) => u.id === id)

  if (index === -1) {
    return res.status(404).json({
      error: { message: `ID: ${id} のユーザーが見つかりません` },
    })
  }

  // 送信されたフィールドのみ更新
  users[index] = { ...users[index], ...req.body, id } // idは上書きさせない
  res.json(users[index])
})

// ========================================
// DELETE - ユーザーの削除
// DELETE /api/users/:id
// ========================================
app.delete('/api/users/:id', (req, res) => {
  const id = parseInt(req.params.id)
  const index = users.findIndex((u) => u.id === id)

  if (index === -1) {
    return res.status(404).json({
      error: { message: `ID: ${id} のユーザーが見つかりません` },
    })
  }

  users.splice(index, 1)
  res.status(204).send() // No Content
})

app.listen(3000, () => {
  console.log('APIサーバーが http://localhost:3000 で起動しました')
})

curlでのテスト方法

# ユーザー一覧の取得
curl http://localhost:3000/api/users

# ページネーション付き
curl "http://localhost:3000/api/users?page=1&limit=2&sort=name&order=asc"

# 個別ユーザーの取得
curl http://localhost:3000/api/users/1

# ユーザーの作成
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "三郎", "email": "saburo@example.com", "age": 28}'

# ユーザーの全体更新
curl -X PUT http://localhost:3000/api/users/1 \
  -H "Content-Type: application/json" \
  -d '{"name": "太郎(更新)", "email": "taro-new@example.com", "age": 26}'

# ユーザーの部分更新
curl -X PATCH http://localhost:3000/api/users/1 \
  -H "Content-Type: application/json" \
  -d '{"age": 27}'

# ユーザーの削除
curl -X DELETE http://localhost:3000/api/users/1

クエリパラメータとパスパラメータ

パスパラメータ

リソースを特定するために使う。URLパスの一部として含まれる。

GET /api/users/123          → ID:123のユーザーを取得
GET /api/users/123/posts/45 → ユーザー123の投稿45を取得

クエリパラメータ

リソースのフィルタリング、ソート、ページネーションに使う。?key=valueの形式。

GET /api/users?role=admin           → 管理者ユーザーのみ取得
GET /api/users?sort=name&order=asc  → 名前昇順でソート
GET /api/users?page=2&limit=20      → 2ページ目、20件ずつ
GET /api/users?q=太郎              → 「太郎」で検索

使い分けの指針

種類使い方
パスパラメータリソースの一意な識別に必須/users/:id
クエリパラメータ任意のフィルタリング・オプション?page=1&sort=name

パスパラメータはないとリソースが特定できないもの、クエリパラメータはなくてもデフォルト値で動作するものに使う。

ページネーションの実装パターン

// オフセットベース(一般的)
// GET /api/users?page=3&limit=20
app.get('/api/users', (req, res) => {
  const page = parseInt(req.query.page) || 1
  const limit = parseInt(req.query.limit) || 20
  const offset = (page - 1) * limit

  // DBの場合: SELECT * FROM users LIMIT 20 OFFSET 40
  const paginatedData = allData.slice(offset, offset + limit)

  res.json({
    data: paginatedData,
    pagination: {
      page,
      limit,
      totalItems: allData.length,
      totalPages: Math.ceil(allData.length / limit),
      hasNext: offset + limit < allData.length,
      hasPrev: page > 1,
    },
  })
})

// カーソルベース(大量データ向け、SNSのフィードなど)
// GET /api/posts?cursor=abc123&limit=20
app.get('/api/posts', (req, res) => {
  const cursor = req.query.cursor // 最後に取得したアイテムのID等
  const limit = parseInt(req.query.limit) || 20

  let data
  if (cursor) {
    const cursorIndex = allData.findIndex((item) => item.id === cursor)
    data = allData.slice(cursorIndex + 1, cursorIndex + 1 + limit)
  } else {
    data = allData.slice(0, limit)
  }

  const nextCursor = data.length === limit ? data[data.length - 1].id : null

  res.json({
    data,
    pagination: {
      nextCursor,
      hasMore: nextCursor !== null,
    },
  })
})

エラーハンドリング

統一的なエラーレスポンス形式

チーム全体で一貫したエラーレスポンス形式を決めておくと、フロントエンド開発者がエラー処理を書きやすくなる。

// エラーレスポンスの統一形式
// {
//   "error": {
//     "code": "エラーコード(マシンリーダブル)",
//     "message": "エラーメッセージ(人が読む用)",
//     "details": [詳細情報(任意)]
//   }
// }

// カスタムエラークラス
class ApiError extends Error {
  constructor(statusCode, code, message, details = []) {
    super(message)
    this.statusCode = statusCode
    this.code = code
    this.details = details
  }
}

// よく使うエラーのファクトリ関数
const errors = {
  notFound: (resource) => new ApiError(404, 'NOT_FOUND', `${resource}が見つかりません`),
  badRequest: (message, details) => new ApiError(400, 'BAD_REQUEST', message, details),
  unauthorized: () => new ApiError(401, 'UNAUTHORIZED', '認証が必要です'),
  forbidden: () => new ApiError(403, 'FORBIDDEN', 'アクセス権限がありません'),
  conflict: (message) => new ApiError(409, 'CONFLICT', message),
  internal: () => new ApiError(500, 'INTERNAL_ERROR', 'サーバー内部エラーが発生しました'),
}

// 使用例
app.get('/api/users/:id', (req, res, next) => {
  const user = users.find((u) => u.id === parseInt(req.params.id))
  if (!user) {
    return next(errors.notFound('ユーザー'))
  }
  res.json(user)
})

// エラーハンドリングミドルウェア
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500
  const response = {
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: err.message || 'サーバー内部エラーが発生しました',
    },
  }

  if (err.details && err.details.length > 0) {
    response.error.details = err.details
  }

  // 開発環境ではスタックトレースを含める
  if (process.env.NODE_ENV === 'development') {
    response.error.stack = err.stack
  }

  console.error(`[${statusCode}] ${err.code}: ${err.message}`)
  res.status(statusCode).json(response)
})

バリデーション

zodを使ったバリデーション

zodはTypeScriptフレンドリーなバリデーションライブラリ。スキーマを定義すると、型推論も自動で行われる。

npm install zod
const { z } = require('zod')

// ユーザー作成用のスキーマ定義
const createUserSchema = z.object({
  name: z
    .string({ required_error: '名前は必須です' })
    .min(2, '名前は2文字以上で入力してください')
    .max(50, '名前は50文字以内で入力してください'),
  email: z
    .string({ required_error: 'メールアドレスは必須です' })
    .email('有効なメールアドレスを入力してください'),
  age: z
    .number()
    .int('年齢は整数で入力してください')
    .min(0, '年齢は0以上で入力してください')
    .max(150, '年齢は150以下で入力してください')
    .optional(),
  role: z
    .enum(['user', 'admin', 'moderator'], {
      errorMap: () => ({ message: 'roleはuser, admin, moderatorのいずれかです' }),
    })
    .default('user'),
})

// ユーザー更新用(部分更新対応)
const updateUserSchema = createUserSchema.partial() // 全フィールドをoptionalにする

// バリデーションミドルウェア
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body)
    if (!result.success) {
      const details = result.error.errors.map((err) => ({
        field: err.path.join('.'),
        message: err.message,
      }))
      return res.status(400).json({
        error: {
          code: 'VALIDATION_ERROR',
          message: '入力値が不正です',
          details,
        },
      })
    }
    req.validatedBody = result.data // バリデーション済みのデータをreqに追加
    next()
  }
}

// ルートで使用
app.post('/api/users', validate(createUserSchema), (req, res) => {
  const userData = req.validatedBody // バリデーション済み
  // ユーザー作成処理...
  res.status(201).json({ id: nextId++, ...userData })
})

app.patch('/api/users/:id', validate(updateUserSchema), (req, res) => {
  const updateData = req.validatedBody
  // ユーザー更新処理...
  res.json({ id: parseInt(req.params.id), ...updateData })
})

CORS(Cross-Origin Resource Sharing)

なぜCORSが必要か

ブラウザには同一オリジンポリシー(Same-Origin Policy) というセキュリティ機能がある。異なるオリジン(ドメイン、ポート、プロトコルの組み合わせ)へのリクエストをデフォルトで制限する。

オリジン = プロトコル + ドメイン + ポート

https://myapp.com:443  → 1つのオリジン
http://myapp.com:80    → 別のオリジン(プロトコルが違う)
https://api.myapp.com  → 別のオリジン(ドメインが違う)
https://myapp.com:3000 → 別のオリジン(ポートが違う)

開発中によくあるケース:

  • フロントエンド: http://localhost:3000(React等)
  • バックエンド: http://localhost:8080(Express等)

ポートが異なるので「別オリジン」とみなされ、ブラウザがリクエストをブロックする。CORSはこの制限を適切に緩和する仕組み。

CORSの設定方法

npm install cors
const cors = require('cors')

// 開発環境: 全てのオリジンを許可(本番では使わない)
app.use(cors())

// 本番環境: 特定のオリジンのみ許可
const corsOptions = {
  origin: (origin, callback) => {
    const allowedOrigins = ['https://myapp.com', 'https://admin.myapp.com']
    // originがnullになるケース(同一オリジンのリクエスト等)も許可
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true)
    } else {
      callback(new Error('CORSポリシーにより拒否されました'))
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true, // Cookie送信を許可する場合
  maxAge: 86400, // プリフライトリクエストのキャッシュ(24時間)
}

app.use(cors(corsOptions))

プリフライトリクエスト

「単純リクエスト」でない場合(カスタムヘッダーを使う、PUTやDELETEメソッドを使うなど)、ブラウザは本リクエストの前にOPTIONSリクエスト(プリフライト) を送信して許可を確認する。

ブラウザ                              サーバー
  |                                     |
  |--- OPTIONS /api/users (プリフライト)-->|  「PUT送ってもいい?」
  |<-- 200 + Access-Control-* ヘッダー ----|  「OKだよ」
  |                                     |
  |--- PUT /api/users/1 (本リクエスト) --->|  「では更新します」
  |<-- 200 + データ ----------------------|  「更新完了」

APIドキュメント

Swagger / OpenAPI

OpenAPI Specification(旧Swagger)は、REST APIの仕様を記述するための標準フォーマット。YAML/JSONでAPIの構造を定義すると、自動でドキュメントUIが生成される。

npm install swagger-ui-express swagger-jsdoc
const swaggerUi = require('swagger-ui-express')
const swaggerJsdoc = require('swagger-jsdoc')

const swaggerOptions = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'ユーザー管理API',
      version: '1.0.0',
      description: 'ユーザーのCRUD操作を提供するAPI',
    },
    servers: [{ url: 'http://localhost:3000', description: '開発サーバー' }],
  },
  apis: ['./routes/*.js'], // JSDocコメントからスキーマを読み取る
}

const swaggerSpec = swaggerJsdoc(swaggerOptions)
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec))
// http://localhost:3000/api-docs でドキュメントUIにアクセスできる
// routes/users.js - JSDocでAPIを記述する例

/**
 * @openapi
 * /api/users:
 *   get:
 *     summary: ユーザー一覧の取得
 *     tags: [Users]
 *     parameters:
 *       - in: query
 *         name: page
 *         schema:
 *           type: integer
 *           default: 1
 *         description: ページ番号
 *       - in: query
 *         name: limit
 *         schema:
 *           type: integer
 *           default: 20
 *         description: 1ページあたりの件数
 *     responses:
 *       200:
 *         description: ユーザー一覧
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 data:
 *                   type: array
 *                   items:
 *                     $ref: '#/components/schemas/User'
 *                 pagination:
 *                   $ref: '#/components/schemas/Pagination'
 */

Postmanの使い方

Postmanは、APIのテスト・開発に使われるGUIツール。curlコマンドを打たなくても、視覚的にリクエストを送信しレスポンスを確認できる。

機能説明
リクエスト送信GUIでメソッド、URL、ヘッダー、ボディを設定して送信
コレクションAPIリクエストをグループ化して管理
環境変数開発/本番でURLを切り替え
テストレスポンスの自動テスト(JavaScriptで記述)
モックAPIがまだない状態でフロントエンド開発可能
ドキュメント生成コレクションからAPIドキュメントを自動生成

REST vs GraphQL vs gRPC

項目RESTGraphQLgRPC
開発者Roy Fielding(2000年)Facebook(2015年)Google(2015年)
プロトコルHTTPHTTPHTTP/2
データ形式JSONJSONProtocol Buffers(バイナリ)
エンドポイント複数(リソースごと)単一(/graphqlサービスごと
データ取得サーバーが決めた形式クライアントが必要なフィールドを指定スキーマで定義
オーバーフェッチ起きやすい起きにくい起きにくい
アンダーフェッチ起きやすい起きにくい起きにくい
リアルタイムWebSocket等で対応Subscriptionストリーミング
型安全性なし(OpenAPIで補完)スキーマで保証Protocol Buffersで保証
学習コスト低い中程度高い
向いている場面汎用的なWeb API複雑なデータ取得、モバイルアプリマイクロサービス間通信
ブラウザ対応ネイティブ対応ライブラリが必要gRPC-Web経由

オーバーフェッチ: 必要ないデータまで返ってくること。例えばユーザー名だけ欲しいのに全フィールドが返ってくる。

アンダーフェッチ: 1回のリクエストで必要なデータが取れず、複数回リクエストが必要になること。

【RESTの場合: ユーザーとその投稿を取得するには2回リクエストが必要】
GET /api/users/1          → ユーザー情報
GET /api/users/1/posts    → そのユーザーの投稿一覧

【GraphQLの場合: 1回のリクエストで必要なデータだけ取得できる】
POST /graphql
{
  user(id: 1) {
    name
    posts {
      title
      createdAt
    }
  }
}

最初に学ぶならRESTが推奨。RESTの概念を理解した上でGraphQLやgRPCに進むとスムーズに理解できる。

レート制限(Rate Limiting)

なぜ必要か

  • DDoS攻撃の緩和(大量のリクエストでサーバーをダウンさせる攻撃)
  • スクレイピングの防止
  • ブルートフォース攻撃の防止(パスワードの総当たり)
  • APIの公平な使用の保証(1人のユーザーがリソースを独占しない)

実装方法

npm install express-rate-limit
const rateLimit = require('express-rate-limit')

// 一般的なAPI用
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分
  max: 100, // 15分間に100リクエストまで
  standardHeaders: true, // RateLimit-* ヘッダーを返す
  legacyHeaders: false,
  message: {
    error: {
      code: 'RATE_LIMIT_EXCEEDED',
      message: 'リクエスト回数の上限に達しました。15分後に再試行してください。',
    },
  },
})

// ログイン用(厳しめ)
const loginLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1時間
  max: 5, // 1時間に5回まで
  message: {
    error: {
      code: 'LOGIN_RATE_LIMIT',
      message: 'ログイン試行回数の上限に達しました。1時間後に再試行してください。',
    },
  },
})

app.use('/api/', apiLimiter)
app.post('/api/login', loginLimiter, loginHandler)

レスポンスヘッダーに含まれるレート制限情報:

RateLimit-Limit: 100          ← 上限
RateLimit-Remaining: 95       ← 残り回数
RateLimit-Reset: 1711612800   ← リセット時刻(Unix timestamp)

実践例: ブログAPIの設計と実装

API設計

POST   /api/v1/posts          → 記事の作成
GET    /api/v1/posts          → 記事一覧の取得(ページネーション、検索、フィルタ対応)
GET    /api/v1/posts/:id      → 記事の個別取得
PUT    /api/v1/posts/:id      → 記事の全体更新
PATCH  /api/v1/posts/:id      → 記事の部分更新
DELETE /api/v1/posts/:id      → 記事の削除
GET    /api/v1/posts/:id/comments → 記事のコメント一覧
POST   /api/v1/posts/:id/comments → 記事にコメント追加

全コード

const express = require('express')
const { z } = require('zod')
const cors = require('cors')
const rateLimit = require('express-rate-limit')

const app = express()

// ミドルウェア
app.use(express.json())
app.use(cors())
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }))

// 仮のデータベース
let posts = [
  {
    id: 1,
    title: 'Node.js入門',
    body: 'Node.jsはサーバーサイドJavaScriptのランタイム環境です。',
    author: '太郎',
    tags: ['Node.js', '入門'],
    published: true,
    createdAt: '2026-03-01T10:00:00Z',
    updatedAt: '2026-03-01T10:00:00Z',
  },
  {
    id: 2,
    title: 'Express.jsガイド',
    body: 'Express.jsはNode.jsの軽量Webフレームワークです。',
    author: '花子',
    tags: ['Express', 'Node.js'],
    published: true,
    createdAt: '2026-03-15T14:30:00Z',
    updatedAt: '2026-03-15T14:30:00Z',
  },
]
let comments = [
  {
    id: 1,
    postId: 1,
    author: '花子',
    body: 'とても参考になりました!',
    createdAt: '2026-03-02T09:00:00Z',
  },
]
let nextPostId = 3
let nextCommentId = 2

// バリデーションスキーマ
const createPostSchema = z.object({
  title: z.string().min(1, 'タイトルは必須です').max(200, 'タイトルは200文字以内です'),
  body: z.string().min(1, '本文は必須です'),
  author: z.string().min(1, '著者名は必須です'),
  tags: z.array(z.string()).default([]),
  published: z.boolean().default(false),
})

const updatePostSchema = createPostSchema.partial()

const createCommentSchema = z.object({
  author: z.string().min(1, '名前は必須です'),
  body: z.string().min(1, 'コメントは必須です').max(1000, 'コメントは1000文字以内です'),
})

// バリデーションミドルウェア
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body)
    if (!result.success) {
      return res.status(400).json({
        error: {
          code: 'VALIDATION_ERROR',
          message: '入力値が不正です',
          details: result.error.errors.map((e) => ({
            field: e.path.join('.'),
            message: e.message,
          })),
        },
      })
    }
    req.validatedBody = result.data
    next()
  }
}

// ルーター
const router = express.Router()

// 記事の作成
router.post('/posts', validate(createPostSchema), (req, res) => {
  const now = new Date().toISOString()
  const post = {
    id: nextPostId++,
    ...req.validatedBody,
    createdAt: now,
    updatedAt: now,
  }
  posts.push(post)
  res.status(201).json(post)
})

// 記事一覧の取得
router.get('/posts', (req, res) => {
  let result = [...posts]

  // 公開状態でフィルタ
  if (req.query.published !== undefined) {
    const isPublished = req.query.published === 'true'
    result = result.filter((p) => p.published === isPublished)
  }

  // タグでフィルタ
  if (req.query.tag) {
    result = result.filter((p) => p.tags.includes(req.query.tag))
  }

  // キーワード検索
  if (req.query.q) {
    const keyword = req.query.q.toLowerCase()
    result = result.filter(
      (p) => p.title.toLowerCase().includes(keyword) || p.body.toLowerCase().includes(keyword)
    )
  }

  // ソート
  const sortField = req.query.sort || 'createdAt'
  const sortOrder = req.query.order === 'asc' ? 1 : -1
  result.sort((a, b) => {
    if (a[sortField] < b[sortField]) return -1 * sortOrder
    if (a[sortField] > b[sortField]) return 1 * sortOrder
    return 0
  })

  // ページネーション
  const page = parseInt(req.query.page) || 1
  const limit = parseInt(req.query.limit) || 10
  const offset = (page - 1) * limit
  const paginatedData = result.slice(offset, offset + limit)

  res.json({
    data: paginatedData,
    pagination: {
      page,
      limit,
      totalItems: result.length,
      totalPages: Math.ceil(result.length / limit),
      hasNext: offset + limit < result.length,
      hasPrev: page > 1,
    },
  })
})

// 記事の個別取得
router.get('/posts/:id', (req, res) => {
  const post = posts.find((p) => p.id === parseInt(req.params.id))
  if (!post) {
    return res.status(404).json({
      error: { code: 'NOT_FOUND', message: '記事が見つかりません' },
    })
  }
  res.json(post)
})

// 記事の全体更新
router.put('/posts/:id', validate(createPostSchema), (req, res) => {
  const index = posts.findIndex((p) => p.id === parseInt(req.params.id))
  if (index === -1) {
    return res.status(404).json({
      error: { code: 'NOT_FOUND', message: '記事が見つかりません' },
    })
  }
  posts[index] = {
    ...posts[index],
    ...req.validatedBody,
    id: posts[index].id,
    createdAt: posts[index].createdAt,
    updatedAt: new Date().toISOString(),
  }
  res.json(posts[index])
})

// 記事の部分更新
router.patch('/posts/:id', validate(updatePostSchema), (req, res) => {
  const index = posts.findIndex((p) => p.id === parseInt(req.params.id))
  if (index === -1) {
    return res.status(404).json({
      error: { code: 'NOT_FOUND', message: '記事が見つかりません' },
    })
  }
  posts[index] = {
    ...posts[index],
    ...req.validatedBody,
    id: posts[index].id,
    createdAt: posts[index].createdAt,
    updatedAt: new Date().toISOString(),
  }
  res.json(posts[index])
})

// 記事の削除
router.delete('/posts/:id', (req, res) => {
  const index = posts.findIndex((p) => p.id === parseInt(req.params.id))
  if (index === -1) {
    return res.status(404).json({
      error: { code: 'NOT_FOUND', message: '記事が見つかりません' },
    })
  }
  posts.splice(index, 1)
  // 関連コメントも削除
  comments = comments.filter((c) => c.postId !== parseInt(req.params.id))
  res.status(204).send()
})

// コメント一覧の取得
router.get('/posts/:id/comments', (req, res) => {
  const postId = parseInt(req.params.id)
  const post = posts.find((p) => p.id === postId)
  if (!post) {
    return res.status(404).json({
      error: { code: 'NOT_FOUND', message: '記事が見つかりません' },
    })
  }
  const postComments = comments.filter((c) => c.postId === postId)
  res.json({ data: postComments })
})

// コメントの追加
router.post('/posts/:id/comments', validate(createCommentSchema), (req, res) => {
  const postId = parseInt(req.params.id)
  const post = posts.find((p) => p.id === postId)
  if (!post) {
    return res.status(404).json({
      error: { code: 'NOT_FOUND', message: '記事が見つかりません' },
    })
  }
  const comment = {
    id: nextCommentId++,
    postId,
    ...req.validatedBody,
    createdAt: new Date().toISOString(),
  }
  comments.push(comment)
  res.status(201).json(comment)
})

// ルーターをマウント
app.use('/api/v1', router)

// 404ハンドラー
app.use((req, res) => {
  res.status(404).json({
    error: { code: 'NOT_FOUND', message: 'エンドポイントが見つかりません' },
  })
})

// エラーハンドリング
app.use((err, req, res, next) => {
  console.error(err)
  res.status(500).json({
    error: { code: 'INTERNAL_ERROR', message: 'サーバー内部エラーが発生しました' },
  })
})

const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
  console.log(`ブログAPIサーバーが http://localhost:${PORT} で起動しました`)
  console.log(`APIドキュメント: http://localhost:${PORT}/api-docs`)
})

まとめ

RESTful APIの設計で押さえるべきポイント:

カテゴリポイント
URI設計リソースは名詞・複数形、階層は3段階まで
HTTPメソッドCRUD操作を適切なメソッドで表現
ステータスコード意味に合ったコードを返す(200, 201, 400, 404, 500等)
レスポンス形式統一されたJSON形式(特にエラーレスポンス)
バリデーションサーバー側で必ず入力を検証
セキュリティCORS, レート制限, 入力バリデーション
ドキュメントOpenAPI/Swaggerで仕様を記述

REST APIの設計スキルは、バックエンド開発だけでなくフロントエンド開発においても重要。APIの仕組みを理解していれば、フロントエンドからのデータ取得やエラーハンドリングが格段にスムーズになる。

参考リンク