はじめに
「とりあえずPOSTで送っておけばいい」 「ステータスコードは200か500でいいでしょ」 「ヘッダー?気にしたことない」
こういうAPI、よく見かける。 動くには動く。でも、後から必ず苦労する。
HTTPは「なんとなく」で使えてしまうプロトコルだ。 だからこそ、本質を理解している人と、していない人で差がつく。
この記事では、HTTPの本質から始めて、実務で使えるAPI設計パターンまでを解説する。
HTTPの本質:リクエストとレスポンス
HTTPは、シンプルなプロトコルだ。
sequenceDiagram
participant Client as クライアント
participant Server as サーバー
Client->>Server: リクエスト
Server->>Client: レスポンス
これだけ。
リクエストの構造
POST /api/users HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: Bearer xxxx
{"name": "山田太郎", "email": "yamada@example.com"}
| 要素 | 説明 |
|---|---|
| メソッド | POST(何をするか) |
| パス | /api/users(どこに対して) |
| ヘッダー | メタ情報(認証、形式など) |
| ボディ | 実際のデータ |
レスポンスの構造
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/users/123
{"id": 123, "name": "山田太郎", "email": "yamada@example.com"}
| 要素 | 説明 |
|---|---|
| ステータスコード | 201(結果) |
| ヘッダー | メタ情報(新規リソースの場所など) |
| ボディ | レスポンスデータ |
この構造を理解していれば、どんなAPIでも読める。
HTTPメソッドの本質
なぜメソッドを使い分けるのか
「全部POSTでいいじゃん」という人がいる。 確かに、技術的には可能だ。
でも、メソッドには意味がある。 意味があるから、クライアントもサーバーも適切に振る舞える。
各メソッドの意味
GET → 取得する(副作用なし)
POST → 作成する(新しいリソースを作る)
PUT → 置換する(リソース全体を入れ替える)
PATCH → 部分更新する(一部だけ変える)
DELETE → 削除する
HTTPメソッド選択フローチャート
flowchart TB
Start["何をしたい?"] --> Q1{操作の種類は?}
Q1 -->|データ取得| GET["✅ GET<br/>━━━━━━<br/>・リソースの取得<br/>・検索<br/>・一覧表示<br/><br/>特性:<br/>・安全(副作用なし)<br/>・冪等<br/>・キャッシュ可能"]
Q1 -->|データ作成| POST["✅ POST<br/>━━━━━━<br/>・新規リソース作成<br/>・ログイン<br/>・検索(複雑な条件)<br/><br/>特性:<br/>・非安全<br/>・非冪等<br/>・冪等キー推奨"]
Q1 -->|データ更新| Q2{更新方法は?}
Q2 -->|全体を置換| PUT["✅ PUT<br/>━━━━━━<br/>・リソース全体の置換<br/>・全フィールド必須<br/><br/>特性:<br/>・非安全<br/>・冪等<br/>・リトライ安全"]
Q2 -->|一部だけ変更| PATCH["✅ PATCH<br/>━━━━━━<br/>・部分的な更新<br/>・変更フィールドのみ<br/><br/>特性:<br/>・非安全<br/>・非冪等(実装次第)"]
Q1 -->|データ削除| DELETE["✅ DELETE<br/>━━━━━━<br/>・リソースの削除<br/><br/>特性:<br/>・非安全<br/>・冪等<br/>・リトライ安全"]
style GET fill:#e8f5e9
style POST fill:#fff3e0
style PUT fill:#e3f2fd
style PATCH fill:#e3f2fd
style DELETE fill:#ffebee
【重要】安全性と冪等性
この概念を理解していないと、障害を起こす。
| メソッド | 安全 | 冪等 |
|---|---|---|
| GET | ✅ | ✅ |
| POST | ❌ | ❌ |
| PUT | ❌ | ✅ |
| PATCH | ❌ | ❌ |
| DELETE | ❌ | ✅ |
安全(Safe): サーバーの状態を変えない 冪等(Idempotent): 何度実行しても結果が同じ
実務での意味
# GETは安全 → キャッシュできる、プリフェッチできる
GET /api/users/123
# ブラウザは「このGETリクエスト、先に取得しておこう」ができる
# なぜなら、副作用がないと保証されているから
# POSTは冪等じゃない → リトライが危険
POST /api/orders
{"item": "商品A", "quantity": 1}
# タイムアウトしたからリトライ → 2重注文の可能性
# だから、POSTには冪等キーを使う(後述)
# PUTは冪等 → リトライしても安全
PUT /api/users/123
{"name": "山田太郎", "email": "yamada@example.com"}
# 何回実行しても、結果は同じ
# リトライしても問題ない
【実務】GETで更新してはいけない理由
たまに見かける、危険なAPI:
# 絶対にやってはいけない
GET /api/users/123/delete
GET /api/orders/456/cancel
なぜダメか?
1. ブラウザのプリフェッチ
<!-- ブラウザが「このリンク、先に読み込んでおこう」と判断 -->
<a href="/api/users/123/delete">削除</a>
<!-- ユーザーがクリックする前に、削除されてしまう可能性 -->
2. クローラーのアクセス
# Googlebotが巡回
GET /api/users/123/delete
# 「GETは安全」という前提でアクセス
# でも実際は削除される → データ消失
3. キャッシュの問題
# CDNやプロキシがキャッシュ
GET /api/users/123/delete
# キャッシュから返される
# 「削除したはず」なのに、されていない
GETは副作用なし。これは絶対のルール。
なぜGETで更新すると危険なのか
flowchart TB
BadAPI["❌ 悪い例:<br/><code style='color: white'>GET /api/users/123/delete</code>"]
BadAPI --> Problem1["⚠️ 問題1:ブラウザプリフェッチ"]
BadAPI --> Problem2["⚠️ 問題2:クローラーアクセス"]
BadAPI --> Problem3["⚠️ 問題3:キャッシュ"]
Problem1 --> Detail1["🌐 ブラウザ<br/>━━━━━━<br/>「このリンク先を<br/>先読みしておこう」<br/><br/>→ ユーザーがクリックする前に<br/> データが削除される!"]
Problem2 --> Detail2["🤖 Googlebot<br/>━━━━━━<br/>「GETは安全だから<br/>アクセスしても問題ない」<br/><br/>→ クローラー巡回で<br/> データが削除される!"]
Problem3 --> Detail3["💾 CDN/プロキシ<br/>━━━━━━<br/>「GETはキャッシュして<br/>次回は保存済みを返そう」<br/><br/>→ 削除リクエストが<br/> キャッシュから返される<br/> (削除されない)"]
Detail1 --> Solution["✅ 正しい実装"]
Detail2 --> Solution
Detail3 --> Solution
Solution --> Correct["<code style='color: white'>DELETE /api/users/123</code><br/>━━━━━━<br/>・副作用がある操作は<br/> DELETEメソッド<br/>・ブラウザもクローラーも<br/> 勝手にアクセスしない<br/>・キャッシュされない"]
style BadAPI fill:#ffebee
style Problem1 fill:#fff3e0
style Problem2 fill:#fff3e0
style Problem3 fill:#fff3e0
style Detail1 fill:#ffebee
style Detail2 fill:#ffebee
style Detail3 fill:#ffebee
style Correct fill:#e8f5e9
ステータスコードの本質
なぜ適切なステータスコードが重要か
「200で返して、bodyにエラー入れればいい」
// こういうAPI、よく見る
HTTP/1.1 200 OK
{"success": false, "error": "ユーザーが見つかりません"}
これの問題:
- HTTPクライアントが正常と判断する
- 監視ツールがエラーを検知できない
- CDNがエラーレスポンスをキャッシュする
ステータスコードは、HTTPレイヤーでの「結果の要約」だ。 bodyを見なくても、何が起きたかわかるようにする。
主要なステータスコード
2xx: 成功
200 OK # 成功(一般的)
201 Created # 作成成功(POSTの成功)
204 No Content # 成功だがbodyなし(DELETEの成功)
3xx: リダイレクト
301 Moved Permanently # 恒久的な移動(SEOに影響)
302 Found # 一時的な移動
304 Not Modified # キャッシュを使え
4xx: クライアントエラー
400 Bad Request # リクエストが不正
401 Unauthorized # 認証が必要
403 Forbidden # 認証済みだが権限がない
404 Not Found # リソースが存在しない
409 Conflict # 競合(楽観ロックの失敗など)
422 Unprocessable Entity # バリデーションエラー
429 Too Many Requests # レート制限
5xx: サーバーエラー
500 Internal Server Error # サーバー内部エラー
502 Bad Gateway # 上流サーバーからの不正なレスポンス
503 Service Unavailable # 一時的に利用不可
504 Gateway Timeout # 上流サーバーがタイムアウト
【実務】ステータスコードの選び方
# ユーザー作成API
def create_user(request):
# バリデーションエラー → 422
if not is_valid(request.body):
return Response(status=422, body={"errors": validation_errors})
# メールアドレスが既に存在 → 409
if email_exists(request.body["email"]):
return Response(status=409, body={"error": "Email already exists"})
# 作成成功 → 201 + Locationヘッダー
user = create_user_in_db(request.body)
return Response(
status=201,
headers={"Location": f"/api/users/{user.id}"},
body=user.to_dict()
)
ステータスコード選択フローチャート
flowchart TB
Start["API処理結果"] --> Q1{処理は<br/>成功した?}
Q1 -->|Yes| Q2{何の操作?}
Q2 -->|リソース作成| S201["✅ 201 Created<br/>━━━━━━<br/>+ Locationヘッダー"]
Q2 -->|更新・取得| Q3{レスポンスbodyは?}
Q3 -->|あり| S200["✅ 200 OK"]
Q3 -->|なし| S204["✅ 204 No Content<br/>━━━━━━<br/>(DELETE成功など)"]
Q1 -->|No| Q4{どこで<br/>失敗した?}
Q4 -->|クライアント側| Q5{何が問題?}
Q5 -->|リクエスト形式不正| E400["❌ 400 Bad Request<br/>━━━━━━<br/>・JSON parse error<br/>・必須パラメータ欠如"]
Q5 -->|認証なし| E401["❌ 401 Unauthorized<br/>━━━━━━<br/>・トークンなし<br/>・トークン期限切れ"]
Q5 -->|権限なし| E403["❌ 403 Forbidden<br/>━━━━━━<br/>・認証済みだが<br/> アクセス権限なし"]
Q5 -->|リソース不在| E404["❌ 404 Not Found"]
Q5 -->|バリデーションエラー| E422["❌ 422 Unprocessable Entity<br/>━━━━━━<br/>・メール形式不正<br/>・値が範囲外"]
Q5 -->|競合| E409["❌ 409 Conflict<br/>━━━━━━<br/>・メールアドレス重複<br/>・楽観ロック失敗"]
Q5 -->|レート制限| E429["❌ 429 Too Many Requests<br/>━━━━━━<br/>+ Retry-Afterヘッダー"]
Q4 -->|サーバー側| Q6{種類は?}
Q6 -->|内部エラー| E500["❌ 500 Internal Server Error<br/>━━━━━━<br/>・想定外の例外<br/>・DB接続失敗"]
Q6 -->|上流エラー| E502["❌ 502 Bad Gateway<br/>━━━━━━<br/>・外部API異常レスポンス"]
Q6 -->|一時的不可| E503["❌ 503 Service Unavailable<br/>━━━━━━<br/>・メンテナンス中<br/>・負荷過多"]
Q6 -->|上流タイムアウト| E504["❌ 504 Gateway Timeout"]
style S200 fill:#e8f5e9
style S201 fill:#e8f5e9
style S204 fill:#e8f5e9
style E400 fill:#fff3e0
style E401 fill:#fff3e0
style E403 fill:#fff3e0
style E404 fill:#fff3e0
style E409 fill:#fff3e0
style E422 fill:#fff3e0
style E429 fill:#fff3e0
style E500 fill:#ffebee
style E502 fill:#ffebee
style E503 fill:#ffebee
style E504 fill:#ffebee
HTTPヘッダーの実務
Content-Type: データの形式
Content-Type: application/json # JSON
Content-Type: application/x-www-form-urlencoded # フォーム
Content-Type: multipart/form-data # ファイルアップロード
Content-Type: text/html; charset=utf-8 # HTML
実務での注意:
# JSONを送るとき、Content-Typeを忘れると...
POST /api/users
{"name": "山田太郎"}
# サーバーが「これはJSONじゃない」と判断する可能性
# 常にContent-Type: application/jsonを付ける
Authorization: 認証
# Bearerトークン(JWT、OAuth)
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Basic認証(Base64エンコード)
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
# APIキー(カスタムヘッダーの場合も)
X-API-Key: your-api-key-here
Cache-Control: キャッシュ制御
# キャッシュ禁止(個人情報など)
Cache-Control: no-store
# 1時間キャッシュ可能
Cache-Control: max-age=3600
# 共有キャッシュ(CDN)で1日キャッシュ
Cache-Control: public, max-age=86400
# 再検証が必要
Cache-Control: no-cache
実務での使い分け:
# ユーザー情報 → キャッシュ禁止
GET /api/users/me
Cache-Control: no-store
# 商品一覧 → 短時間キャッシュ
GET /api/products
Cache-Control: max-age=300
# 静的データ(国リストなど) → 長期キャッシュ
GET /api/countries
Cache-Control: max-age=86400
ETag / If-None-Match: 条件付きリクエスト
# 最初のリクエスト
GET /api/users/123
→ 200 OK
→ ETag: "abc123"
# 2回目のリクエスト
GET /api/users/123
If-None-Match: "abc123"
→ 304 Not Modified(変更なし、bodyなし)
帯域節約とパフォーマンス向上に効く。
REST API設計の実務
URLの設計原則
# リソースは名詞、複数形
GET /api/users # ユーザー一覧
GET /api/users/123 # 特定のユーザー
POST /api/users # ユーザー作成
PUT /api/users/123 # ユーザー更新
DELETE /api/users/123 # ユーザー削除
# ネストしたリソース
GET /api/users/123/orders # ユーザー123の注文一覧
GET /api/users/123/orders/456 # ユーザー123の注文456
# ダメな例
GET /api/getUsers # 動詞を入れない
GET /api/user # 複数形にする
POST /api/users/create # createはメソッドで表現
GET /api/users/123/delete # GETで削除しない
クエリパラメータの使い方
# フィルタリング
GET /api/users?status=active&role=admin
# ソート
GET /api/users?sort=created_at&order=desc
# ページネーション
GET /api/users?page=2&per_page=20
# 部分取得(フィールド指定)
GET /api/users?fields=id,name,email
ページネーションの実装
オフセットベース(シンプルだが大規模で遅い)
GET /api/users?page=2&per_page=20
{
"data": [...],
"pagination": {
"current_page": 2,
"per_page": 20,
"total": 1000,
"total_pages": 50
}
}
-- 内部的には OFFSET を使う
SELECT * FROM users LIMIT 20 OFFSET 20;
-- 問題: OFFSETが大きくなると遅くなる
SELECT * FROM users LIMIT 20 OFFSET 10000; -- 遅い!
カーソルベース(大規模向け)
GET /api/users?cursor=eyJpZCI6MTIzfQ&limit=20
{
"data": [...],
"pagination": {
"next_cursor": "eyJpZCI6MTQzfQ",
"has_more": true
}
}
-- 内部的にはWHEREを使う
SELECT * FROM users WHERE id > 123 ORDER BY id LIMIT 20;
-- どれだけデータがあっても高速
実務での選択:
- 管理画面(データ少ない) → オフセットベース
- 公開API(データ多い) → カーソルベース
- 無限スクロール → カーソルベース一択
ページネーション方式選択フロー
flowchart TB
Start["ページネーションが必要"] --> Q1{データ量は?}
Q1 -->|"小〜中規模<br/>(数千件程度)"| Q2{ページ番号<br/>ジャンプは必要?}
Q2 -->|Yes| Offset["✅ オフセットベース<br/>━━━━━━<br/><code style='color: white'>?page=2&per_page=20</code><br/><br/>メリット:<br/>・実装が簡単<br/>・任意ページへジャンプ可能<br/>・総ページ数を表示できる<br/><br/>デメリット:<br/>・大規模データで遅い<br/>・データ挿入で重複/欠落"]
Q2 -->|No| Cursor["✅ カーソルベース<br/>━━━━━━<br/><code style='color: white'>?cursor=abc&limit=20</code><br/><br/>メリット:<br/>・大規模データでも高速<br/>・データ挿入に強い<br/>・無限スクロール向き<br/><br/>デメリット:<br/>・ページジャンプ不可<br/>・総数がわからない"]
Q1 -->|"大規模<br/>(数万件以上)"| Cursor2["✅ カーソルベース<br/>一択"]
Q1 -->|"SNSフィード<br/>無限スクロール"| Cursor3["✅ カーソルベース<br/>一択"]
style Offset fill:#e3f2fd
style Cursor fill:#e8f5e9
style Cursor2 fill:#e8f5e9
style Cursor3 fill:#e8f5e9
オフセット vs カーソルの技術的違い
flowchart LR
subgraph OffsetMethod["オフセットベース(OFFSET)"]
O1["<code style='color: white'>SELECT * FROM users<br/>LIMIT 20 OFFSET 40</code>"]
O2["⚠️ 問題:<br/>━━━━━━<br/>OFFSETが大きいと<br/>全行スキャンで遅い<br/><br/>例:OFFSET 10000<br/>→ 10000行読み飛ばし"]
end
subgraph CursorMethod["カーソルベース(WHERE)"]
C1["<code style='color: white'>SELECT * FROM users<br/>WHERE id > 40<br/>LIMIT 20</code>"]
C2["✅ 利点:<br/>━━━━━━<br/>インデックスを使用<br/>常に高速<br/><br/>データ量に関係なく<br/>一定のパフォーマンス"]
end
O1 --> O2
C1 --> C2
style OffsetMethod fill:#fff3e0
style CursorMethod fill:#e8f5e9
【実務】API設計でよくある問題と解決策
問題1: N+1リクエスト
# クライアントが何度もリクエストする羽目に
GET /api/orders
→ [{"id": 1, "user_id": 10}, {"id": 2, "user_id": 20}, ...]
GET /api/users/10 # ユーザー情報を取得
GET /api/users/20 # ユーザー情報を取得
...
解決策: 埋め込み or 一括取得
# 方法1: 埋め込み(expand)
GET /api/orders?expand=user
→ [{"id": 1, "user": {"id": 10, "name": "山田"}}, ...]
# 方法2: 一括取得エンドポイント
GET /api/users?ids=10,20,30
→ [{"id": 10, ...}, {"id": 20, ...}, ...]
N+1問題の視覚化と解決策
sequenceDiagram
participant C as クライアント
participant API as APIサーバー
Note over C,API: ❌ 悪い実装:N+1リクエスト
C->>API: GET /api/orders
API->>C: [{id:1, user_id:10}, {id:2, user_id:20}, {id:3, user_id:30}]
Note over C: ユーザー情報を取得するため<br/>ループで個別リクエスト
C->>API: GET /api/users/10
API->>C: {id:10, name:"山田"}
C->>API: GET /api/users/20
API->>C: {id:20, name:"佐藤"}
C->>API: GET /api/users/30
API->>C: {id:30, name:"鈴木"}
Note over C,API: 合計4回のリクエスト!<br/>注文が100件なら101回!
Note over C,API: <br/>✅ 解決策1:expand パラメータ
C->>API: GET /api/orders?expand=user
API->>C: [{id:1, user:{id:10, name:"山田"}}, ...]
Note over C,API: 1回のリクエストで完結!
Note over C,API: <br/>✅ 解決策2:一括取得エンドポイント
C->>API: GET /api/orders
API->>C: [{id:1, user_id:10}, {id:2, user_id:20}, ...]
C->>API: GET /api/users?ids=10,20,30
API->>C: [{id:10, name:"山田"}, {id:20, name:"佐藤"}, ...]
Note over C,API: 2回のリクエストで完結!
問題2: 部分更新の表現
# PUTは全体置換
PUT /api/users/123
{"name": "山田", "email": "yamada@example.com", "status": "active"}
# 一部だけ変えたい場合、全フィールド送る必要がある?
解決策: PATCHを使う
# PATCHは部分更新
PATCH /api/users/123
{"status": "inactive"} # statusだけ変更
問題3: 複雑な操作の表現
# 「パスワードリセット」はどう表現する?
# RESTのCRUDに収まらない
# 方法1: サブリソースとして表現
POST /api/users/123/password-reset
# 方法2: アクションとして表現
POST /api/users/123/actions/reset-password
# 方法3: 独立したリソースとして表現
POST /api/password-reset-requests
{"email": "yamada@example.com"}
問題4: バルク操作
# 100件のユーザーを一括で更新したい
# 100回POSTする?
# 解決策: バルクエンドポイント
PATCH /api/users/bulk
{
"operations": [
{"id": 1, "status": "active"},
{"id": 2, "status": "inactive"},
...
]
}
# レスポンス
{
"results": [
{"id": 1, "success": true},
{"id": 2, "success": false, "error": "Not found"},
...
]
}
【実務】エラーレスポンスの設計
一貫したエラーフォーマット
{
"error": {
"code": "VALIDATION_ERROR",
"message": "入力内容に問題があります",
"details": [
{
"field": "email",
"message": "メールアドレスの形式が正しくありません"
},
{
"field": "password",
"message": "パスワードは8文字以上必要です"
}
]
}
}
エラーコードの設計
# 機械可読なエラーコード
ERROR_CODES = {
"VALIDATION_ERROR": "入力エラー",
"AUTHENTICATION_REQUIRED": "認証が必要",
"PERMISSION_DENIED": "権限がありません",
"RESOURCE_NOT_FOUND": "リソースが見つかりません",
"RESOURCE_CONFLICT": "リソースが競合しています",
"RATE_LIMIT_EXCEEDED": "リクエスト制限を超えました",
"INTERNAL_ERROR": "内部エラー",
}
クライアントはcodeで分岐、messageは表示用:
// クライアント側
if (error.code === "RATE_LIMIT_EXCEEDED") {
// リトライロジック
await sleep(error.retry_after * 1000);
return retry();
}
エラーハンドリングのベストプラクティス
flowchart TB
Request["APIリクエスト受信"] --> Auth{認証トークンは<br/>有効?}
Auth -->|No| E401["❌ 401 Unauthorized<br/>━━━━━━<br/>{<br/> code: 'AUTHENTICATION_REQUIRED',<br/> message: '認証が必要です'<br/>}"]
Auth -->|Yes| Authz{権限は<br/>ある?}
Authz -->|No| E403["❌ 403 Forbidden<br/>━━━━━━<br/>{<br/> code: 'PERMISSION_DENIED',<br/> message: 'アクセス権限がありません'<br/>}"]
Authz -->|Yes| Parse{リクエストbody<br/>パース可能?}
Parse -->|No| E400["❌ 400 Bad Request<br/>━━━━━━<br/>{<br/> code: 'INVALID_JSON',<br/> message: 'JSONが不正です'<br/>}"]
Parse -->|Yes| Validate{バリデーション<br/>OK?}
Validate -->|No| E422["❌ 422 Unprocessable Entity<br/>━━━━━━<br/>{<br/> code: 'VALIDATION_ERROR',<br/> message: '入力内容に問題があります',<br/> details: [{field, message}]<br/>}"]
Validate -->|Yes| Exists{リソースは<br/>存在する?}
Exists -->|No| E404["❌ 404 Not Found<br/>━━━━━━<br/>{<br/> code: 'RESOURCE_NOT_FOUND',<br/> message: 'リソースが見つかりません'<br/>}"]
Exists -->|Yes| Conflict{競合は<br/>ない?}
Conflict -->|Yes| E409["❌ 409 Conflict<br/>━━━━━━<br/>{<br/> code: 'RESOURCE_CONFLICT',<br/> message: 'メールアドレスが既に存在します'<br/>}"]
Conflict -->|No| Process["ビジネスロジック実行"]
Process --> Success{成功?}
Success -->|No| E500["❌ 500 Internal Server Error<br/>━━━━━━<br/>{<br/> code: 'INTERNAL_ERROR',<br/> message: 'サーバーエラーが発生しました'<br/>}<br/><br/>⚠️ スタックトレースはログのみ<br/>クライアントには返さない"]
Success -->|Yes| S200["✅ 200 OK / 201 Created<br/>━━━━━━<br/>{<br/> data: {...}<br/>}"]
style E401 fill:#fff3e0
style E403 fill:#fff3e0
style E400 fill:#fff3e0
style E422 fill:#fff3e0
style E404 fill:#fff3e0
style E409 fill:#fff3e0
style E500 fill:#ffebee
style S200 fill:#e8f5e9
【実務】冪等性の実装
なぜ冪等性が重要か
sequenceDiagram
participant Client as クライアント
participant Server as サーバー
Note over Client: 1回目の注文
Client->>Server: POST /api/orders
Server--xClient: タイムアウト<br/>(実際は成功している)
Note over Client: 「注文できたかわからない<br/>...リトライしよう」
Client->>Server: POST /api/orders<br/>(同じ内容)
Server->>Client: 200 OK
Note over Client,Server: 結果: 2重注文 😱
冪等キーによる解決
# クライアント側
POST /api/orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 # UUIDを生成
{"item": "商品A", "quantity": 1}
# サーバー側
def create_order(request):
idempotency_key = request.headers.get("Idempotency-Key")
# 既に処理済みか確認
cached_response = redis.get(f"idempotency:{idempotency_key}")
if cached_response:
return cached_response # 前回と同じレスポンスを返す
# 新規処理
order = create_order_in_db(request.body)
response = {"id": order.id, "status": "created"}
# レスポンスをキャッシュ(24時間)
redis.setex(f"idempotency:{idempotency_key}", 86400, response)
return response
Stripe、Shopifyなど、決済系APIは必ずこの仕組みを持っている。
【実務】バージョニング
なぜバージョニングが必要か
APIは変化する。 でも、既存のクライアントを壊すわけにはいかない。
バージョニングの方法
URLパス方式(最も一般的)
GET /api/v1/users
GET /api/v2/users
メリット: わかりやすい、キャッシュしやすい デメリット: URLが変わる
ヘッダー方式
GET /api/users
Accept: application/vnd.myapp.v2+json
メリット: URLがきれい デメリット: 実装が複雑、キャッシュしにくい
クエリパラメータ方式
GET /api/users?version=2
メリット: シンプル デメリット: キャッシュキーが複雑になる
実務ではURLパス方式が多い。迷ったらこれ。
後方互換性を保つ変更
# OK: フィールド追加(後方互換)
# v1: {"id": 1, "name": "山田"}
# v2: {"id": 1, "name": "山田", "email": "yamada@example.com"}
# NG: フィールド削除(後方互換性なし)
# v1: {"id": 1, "name": "山田", "email": "..."}
# v2: {"id": 1, "name": "山田"} # emailがなくなった!
# NG: フィールド名変更(後方互換性なし)
# v1: {"user_name": "山田"}
# v2: {"name": "山田"} # フィールド名が変わった!
# NG: 型変更(後方互換性なし)
# v1: {"id": 1}
# v2: {"id": "1"} # 数値から文字列に変わった!
【実務】認証と認可
認証(Authentication)と認可(Authorization)の違い
認証: あなたは誰?(ログイン)
認可: あなたは何ができる?(権限チェック)
JWT(JSON Web Token)
# トークン取得
POST /api/auth/login
{"email": "yamada@example.com", "password": "secret"}
→ {"access_token": "eyJhbGciOiJIUzI1NiIs...", "expires_in": 3600}
# APIアクセス
GET /api/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
JWTの構造:
ヘッダー.ペイロード.署名
eyJhbGciOiJIUzI1NiIs.eyJ1c2VyX2lkIjoxMjM.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
// ペイロード(Base64デコード後)
{
"user_id": 123,
"role": "admin",
"exp": 1702540800 // 有効期限
}
リフレッシュトークン
# アクセストークンは短命(1時間)
# リフレッシュトークンは長命(30日)
# アクセストークン期限切れ時
POST /api/auth/refresh
{"refresh_token": "..."}
→ {"access_token": "新しいトークン", "expires_in": 3600}
なぜ分けるか:
- アクセストークンが漏洩しても、被害は1時間
- リフレッシュトークンは安全に保管(HttpOnly Cookie)
JWT認証フロー(リフレッシュトークン込み)
sequenceDiagram
participant C as クライアント
participant API as APIサーバー
participant DB as データベース
Note over C,API: 1️⃣ 初回ログイン
C->>API: POST /api/auth/login<br/>{email, password}
API->>DB: ユーザー認証
DB->>API: 認証成功
API->>C: 200 OK<br/>{<br/> access_token: "xxx" (1時間),<br/> refresh_token: "yyy" (30日)<br/>}
Note over C: トークンを保存<br/>・access_token: localStorage<br/>・refresh_token: HttpOnly Cookie
Note over C,API: <br/>2️⃣ API呼び出し(通常時)
C->>API: GET /api/users/me<br/>Authorization: Bearer xxx
API->>API: JWTを検証<br/>(署名チェック、有効期限チェック)
API->>C: 200 OK<br/>{user data}
Note over C,API: <br/>3️⃣ アクセストークン期限切れ
C->>API: GET /api/users/me<br/>Authorization: Bearer xxx
API->>API: JWT検証失敗<br/>(期限切れ)
API->>C: 401 Unauthorized<br/>{error: "Token expired"}
Note over C: リフレッシュが必要
C->>API: POST /api/auth/refresh<br/>{refresh_token: "yyy"}
API->>DB: リフレッシュトークンを検証
DB->>API: 有効
API->>C: 200 OK<br/>{<br/> access_token: "new_xxx" (1時間)<br/>}
Note over C: 新しいaccess_tokenで<br/>リトライ
C->>API: GET /api/users/me<br/>Authorization: Bearer new_xxx
API->>C: 200 OK<br/>{user data}
【実務】セキュリティ
CORS(Cross-Origin Resource Sharing)
# サーバー側の設定
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
# Pythonの例(Flask)
from flask_cors import CORS
app = Flask(__name__)
CORS(app, origins=["https://example.com"], supports_credentials=True)
レート制限
# レスポンスヘッダー
X-RateLimit-Limit: 100 # 制限値
X-RateLimit-Remaining: 95 # 残り回数
X-RateLimit-Reset: 1702540800 # リセット時刻(Unix時間)
# 制限超過時
HTTP/1.1 429 Too Many Requests
Retry-After: 60
入力値の検証
# 常にサーバー側でバリデーション
def create_user(request):
# 型チェック
if not isinstance(request.body.get("email"), str):
return error(422, "Email must be a string")
# 形式チェック
if not is_valid_email(request.body["email"]):
return error(422, "Invalid email format")
# 長さチェック
if len(request.body.get("name", "")) > 100:
return error(422, "Name is too long")
# SQLインジェクション対策(プレースホルダーを使う)
cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
(request.body["name"], request.body["email"])
)
実務でのAPI設計チェックリスト
設計時
- URLは名詞・複数形か
- HTTPメソッドは適切か(GETで更新していないか)
- ステータスコードは適切か
- エラーレスポンスは一貫しているか
- ページネーションは実装されているか
- バージョニングは考慮されているか
セキュリティ
- 認証は適切か(JWT、OAuth)
- 認可は適切か(権限チェック)
- CORSは設定されているか
- レート制限は実装されているか
- 入力値は検証されているか
- SQLインジェクション対策はされているか
パフォーマンス
- N+1問題は解決されているか
- キャッシュヘッダーは適切か
- 大量データはページネーションされているか
- 不要なフィールドは返していないか
運用
- ログは出力されているか
- エラーは監視されているか
- ドキュメントは整備されているか(OpenAPI/Swagger)
まとめ
HTTPとAPI設計の本質は、意味のある通信を行うことだ。
- メソッドで「何をするか」を伝える
- ステータスコードで「結果」を伝える
- ヘッダーで「メタ情報」を伝える
- URLで「何に対して」を伝える
これらを適切に使えば、
- クライアントは「何が起きたか」を正確に理解できる
- サーバーは「何をすべきか」を正確に理解できる
- 中間のキャッシュやプロキシも適切に動作する
「動けばいい」から「意味のある設計」へ。
それが、プロのAPI設計だ。