04.1 REST設計原則 — リソース・メソッド・ステータスコードの設計¶
これまでの Module 3 では「HTTP サーバーをどう立てるか」を学びました。このレッスンからは 「立てた HTTP サーバーの API を、どう 設計 するか」に入ります。
REST(REpresentational State Transfer) は、HTTP の上で API を設計するときの 最も広く使われる考え方です。難しい理論ではなく、次の3つの約束にまとめられます。
- リソース指向: URL は「何(名詞)」を指すか。
/getTaskではなく/tasks/1 - 統一インターフェース: HTTP メソッドは「何をするか(動詞)」を表す。GET=読む、POST=作る、PUT=置き換える、DELETE=消す
- 状態をステータスコードで伝える: 200 だけで済ませず、201・204・400・404 を使い分ける
このレッスンのゴール:
- 「良い設計の API」と「悪い設計の API」を実際に立てて、同じ操作に実リクエストを送る
- レスポンスの実際のステータスコードとボディを見比べて、設計の違いが結果にどう出るかを確認する
- 練習問題で「見つからない = エラーではなく 404 という正常な応答」という設計判断を実装する
非自明ポイント¶
1. 冪等性(idempotency)— 同じリクエストを繰り返しても結果が変わらないこと
GET・PUT・DELETEは冪等であるべきです。同じDELETE /tasks/1を3回送っても、 「1回目で消え、2〜3回目は何も起きない(404)」という一貫した結果になるべきですPOSTは冪等ではありません。同じPOST /tasksを3回送れば、3つの別リソースが作られて当然です
2. ステータスコードは「機械が読む契約」
呼び出し側は多くの場合、人間ではなく別のプログラムです。プログラムは
「200 なら成功、404 なら失敗」とステータスコードだけを見て分岐したいのに、
常に 200 を返す API では、ボディを毎回パースしないと成功・失敗が分からず、
分岐ロジックがボディの形式に依存してしまいます。
3. 安全なメソッド(safe method)— GET は状態を変えない、という約束
GET は「読むだけで、サーバーの状態を変えない」という契約です。ブラウザの先読み・
リトライ・キャッシュの仕組みはすべて「GET は安全」という前提の上に成り立っています。
GET に作成・削除のような副作用を持たせると、この前提を壊します。
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync"
"github.com/janpfeifer/gonb/gonbui"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
// Task はこのレッスンで扱う唯一のリソース。
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func renderTable(headers []string, rows [][]string) string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse"><tr>`)
for _, h := range headers {
b.WriteString(fmt.Sprintf("<th>%s</th>", h))
}
b.WriteString("</tr>")
for _, row := range rows {
b.WriteString("<tr>")
for _, cell := range row {
b.WriteString(fmt.Sprintf("<td>%s</td>", cell))
}
b.WriteString("</tr>")
}
b.WriteString("</table>")
return b.String()
}
// writeJSON はステータスコードと JSON ボディを一緒に書き込む共通ヘルパー。
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
1. 良い設計の API — リソース指向 + 正しいステータスコード¶
/tasks を名詞(リソース)として扱い、操作は HTTP メソッドで表します。
GET /tasks/{id}— 存在すれば200、無ければ404(404 はエラーではなく正常な応答)POST /tasks— 検証に失敗すれば400、成功すれば新規作成を表す201(200ではない)DELETE /tasks/{id}— 存在すれば消して204(ボディなし)、無ければ404
// newGoodAPIServer は「良い設計」の REST API を立てる。
// ルーティングは http.NewServeMux()、テストサーバーは呼び出し側が httptest.NewServer で包む。
func newGoodAPIServer() *http.ServeMux {
var mu sync.Mutex // httptest.NewServer はリクエストごとに別goroutineでハンドラを呼ぶため、
// 複数リクエストが同時に来ても store・nextID が壊れないようロックで守る。
store := map[int]*Task{
1: {ID: 1, Title: "牛乳を買う", Done: false},
}
nextID := 2
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
mu.Lock()
t, ok := store[id]
mu.Unlock()
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
writeJSON(w, http.StatusOK, t)
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
mu.Lock()
t := &Task{ID: nextID, Title: body.Title, Done: false}
store[nextID] = t
nextID++
mu.Unlock()
w.Header().Set("Location", fmt.Sprintf("/tasks/%d", t.ID))
writeJSON(w, http.StatusCreated, t)
})
mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
mu.Lock()
_, ok := store[id]
if ok {
delete(store, id)
}
mu.Unlock()
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
return
}
w.WriteHeader(http.StatusNoContent)
})
return mux
}
2. 悪い設計の API — 動詞入りURL + 常に200¶
同じ機能を、よくあるアンチパターンで実装します。
- URL に動詞を埋め込む(
/getTask・/createTask・/deleteTask) - 作成・削除のような副作用のある操作まで
GETにしている(安全なメソッドの約束を破る) - 成功しても失敗しても常に
200を返し、成否をボディの中身でしか判断できない
// newBadAPIServer は「悪い設計」のアンチパターン API を立てる。
func newBadAPIServer() *http.ServeMux {
store := map[int]*Task{
1: {ID: 1, Title: "牛乳を買う", Done: false},
}
nextID := 2
mux := http.NewServeMux()
// 読み取りも書き込みも全部 GET。クエリパラメータで id/title を渡す。
mux.HandleFunc("GET /getTask", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.URL.Query().Get("id"))
t, ok := store[id]
if !ok {
// 見つからなくても 200 を返す。呼び出し側は body を読まないと失敗が分からない。
writeJSON(w, http.StatusOK, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, t)
})
// 作成という副作用のある操作を GET でやっている(安全なメソッドの約束を破る)。
mux.HandleFunc("GET /createTask", func(w http.ResponseWriter, r *http.Request) {
title := r.URL.Query().Get("title")
// タイトルが空でも検証せずに作ってしまう。
t := &Task{ID: nextID, Title: title, Done: false}
store[nextID] = t
nextID++
writeJSON(w, http.StatusOK, t)
})
// 削除という副作用のある操作も GET。存在しなくてもエラーにしない。
mux.HandleFunc("GET /deleteTask", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.URL.Query().Get("id"))
delete(store, id) // 対象が無くても no-op なだけで、成否の区別が付かない
writeJSON(w, http.StatusOK, map[string]string{"result": "ok"})
})
return mux
}
3. 実行して比べる — 同じ6つの操作を両方の API に実リクエストで送る¶
httptest.NewServer で両方の API を実際に起動し、http.Client で本物の HTTP リクエストを送ります。
%%
goodSrv := httptest.NewServer(newGoodAPIServer())
defer goodSrv.Close()
badSrv := httptest.NewServer(newBadAPIServer())
defer badSrv.Close()
client := &http.Client{}
type reqResult struct {
status int
body string
}
doReq := func(method, url string, body io.Reader) reqResult {
req, err := http.NewRequest(method, url, body)
if err != nil {
panic(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
text := strings.TrimSpace(string(b))
if text == "" {
text = "(空)"
}
return reqResult{status: resp.StatusCode, body: text}
}
type scenario struct {
label string
goodMethod, goodPath string
goodBody string
badMethod, badPath string
}
scenarios := []scenario{
{"1. 正常に作成する", "POST", "/tasks", `{"title":"レポート提出"}`, "GET", "/createTask?title=" + url.QueryEscape("レポート提出")},
{"2. タイトル無しで作成する(不正入力)", "POST", "/tasks", `{"title":""}`, "GET", "/createTask?title="},
{"3. 存在するタスクを取得する", "GET", "/tasks/1", "", "GET", "/getTask?id=1"},
{"4. 存在しないタスクを取得する", "GET", "/tasks/999", "", "GET", "/getTask?id=999"},
{"5. 存在するタスクを削除する", "DELETE", "/tasks/1", "", "GET", "/deleteTask?id=1"},
{"6. 同じタスクをもう一度削除する", "DELETE", "/tasks/1", "", "GET", "/deleteTask?id=1"},
}
var rows [][]string
for _, sc := range scenarios {
var goodBody io.Reader
if sc.goodBody != "" {
goodBody = strings.NewReader(sc.goodBody)
}
good := doReq(sc.goodMethod, goodSrv.URL+sc.goodPath, goodBody)
bad := doReq(sc.badMethod, badSrv.URL+sc.badPath, nil)
fmt.Printf("[%s] 良いAPI: %s %s → %d / 悪いAPI: %s %s → %d\n",
sc.label, sc.goodMethod, sc.goodPath, good.status, sc.badMethod, sc.badPath, bad.status)
rows = append(rows, []string{
sc.label,
sc.goodMethod + " " + sc.goodPath,
fmt.Sprintf("%d", good.status),
good.body,
sc.badMethod + " " + sc.badPath,
fmt.Sprintf("%d", bad.status),
bad.body,
})
}
gonbui.DisplayHTML(renderTable([]string{"操作", "良いAPI リクエスト", "良いAPI ステータス", "良いAPI ボディ", "悪いAPI リクエスト", "悪いAPI ステータス", "悪いAPI ボディ"}, rows))
gonbui.Sync()
[1. 正常に作成する] 良いAPI: POST /tasks → 201 / 悪いAPI: GET /createTask?title=%E3%83%AC%E3%83%9D%E3%83%BC%E3%83%88%E6%8F%90%E5%87%BA → 200
[2. タイトル無しで作成する(不正入力)] 良いAPI: POST /tasks → 400 / 悪いAPI: GET /createTask?title= → 200
[3. 存在するタスクを取得する] 良いAPI: GET /tasks/1 → 200 / 悪いAPI: GET /getTask?id=1 → 200
[4. 存在しないタスクを取得する] 良いAPI: GET /tasks/999 → 404 / 悪いAPI: GET /getTask?id=999 → 200
[5. 存在するタスクを削除する] 良いAPI: DELETE /tasks/1 → 204 / 悪いAPI: GET /deleteTask?id=1 → 200
[6. 同じタスクをもう一度削除する] 良いAPI: DELETE /tasks/1 → 404 / 悪いAPI: GET /deleteTask?id=1 → 200
| 操作 | 良いAPI リクエスト | 良いAPI ステータス | 良いAPI ボディ | 悪いAPI リクエスト | 悪いAPI ステータス | 悪いAPI ボディ |
|---|---|---|---|---|---|---|
| 1. 正常に作成する | POST /tasks | 201 | {"id":2,"title":"レポート提出","done":false} | GET /createTask?title=%E3%83%AC%E3%83%9D%E3%83%BC%E3%83%88%E6%8F%90%E5%87%BA | 200 | {"id":2,"title":"レポート提出","done":false} |
| 2. タイトル無しで作成する(不正入力) | POST /tasks | 400 | {"error":"title is required"} | GET /createTask?title= | 200 | {"id":3,"title":"","done":false} |
| 3. 存在するタスクを取得する | GET /tasks/1 | 200 | {"id":1,"title":"牛乳を買う","done":false} | GET /getTask?id=1 | 200 | {"id":1,"title":"牛乳を買う","done":false} |
| 4. 存在しないタスクを取得する | GET /tasks/999 | 404 | {"error":"task not found"} | GET /getTask?id=999 | 200 | {"error":"not found"} |
| 5. 存在するタスクを削除する | DELETE /tasks/1 | 204 | (空) | GET /deleteTask?id=1 | 200 | {"result":"ok"} |
| 6. 同じタスクをもう一度削除する | DELETE /tasks/1 | 404 | {"error":"task not found"} | GET /deleteTask?id=1 | 200 | {"result":"ok"} |
観察点 — 実際の出力から読み取れること¶
表と、その上の fmt.Printf の実出力を見比べてください。
- 操作2(不正な作成): 良い API は
400 Bad Requestで作成を拒否しますが、悪い API は検証をせず200 OKのままタスクを作ってしまいます(空のタイトルが実際に保存されます) - 操作4(存在しないタスクの取得): 良い API は
404 Not Foundで「無い」ことを明示しますが、 悪い API は200 OKのまま、ボディの"error"フィールドを読まないと失敗が分かりません - 操作5・6(削除→再削除): 良い API は 1 回目
204 No Content、2 回目は404 Not Foundと 結果が変わるのに対し、悪い API は 1 回目も 2 回目もまったく同じ200 OK/{"result":"ok"}を返します。 呼び出し側は「本当に削除できたのか」「単にもう一度呼んだだけなのか」を区別できません
同じ Task ストア・同じ操作でも、URL とメソッドとステータスコードの設計次第で、 レスポンスが伝える情報量がまったく変わることが実出力で確認できました。
直感・類推: 窓口とレシート¶
- 良い API = 番号札を渡す窓口。「A-1番の書類はどこ?」(
GET /tasks/1)と聞けば、 あるかないかをその場ではっきり答え(200/404)、新規受付(POST)には受付印付きの 控え(201+Location)を渡してくれます - 悪い API = どんな用件でも「はい、承りました」(
200)としか言わない窓口。 本当に処理できたかは、渡された紙(ボディ)を隅々まで読まないと分かりません
REST 設計は「ステータスコードというレシートの意味を、機械が信頼できる形に揃える」作業だと言えます。
練習問題 4.1: resolveGetTask を実装しよう¶
「良い API」の GET /tasks/{id} と同じロジックを、サーバーを使わない純粋な関数として実装してください。
仕様:
// resolveGetTask は store に id が存在すれば (Task, 200, nil) を返す。
// 存在しなければ (Task{}, 404, nil) を返す。
// —— 「見つからない」はエラーではなく、正常に処理された 404 レスポンスであることに注意。
func resolveGetTask(store map[int]Task, id int) (Task, int, error)
ポイント: このレッスンで学んだ通り、404 は err != nil になるような異常系ではありません。
「そのIDのリソースは存在しない」という正常な結果です。だからこの関数は、見つからない場合でも
error に nil を返します。
// YOUR CODE HERE
// resolveGetTask を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func resolveGetTask(store map[int]Task, id int) (Task, int, error) {
return Task{}, 0, ErrUnanswered
}
チェックのためのヘルパー¶
import "reflect"
import "fmt"
func mustEqual(got, want any, name string) {
if reflect.DeepEqual(got, want) {
fmt.Printf("✅ Passed: %s\n", name)
return
}
panic(fmt.Sprintf("❌ %s\n got = %v (%T)\n want = %v (%T)", name, got, got, want, want))
}
%%
store := map[int]Task{1: {ID: 1, Title: "牛乳を買う", Done: false}}
task, status, err := resolveGetTask(store, 1)
if errors.Is(err, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
mustEqual(err, nil, "存在するIDはエラーなし")
mustEqual(status, 200, "存在するIDは200")
mustEqual(task, Task{ID: 1, Title: "牛乳を買う", Done: false}, "取得したTaskの中身が一致")
task2, status2, err2 := resolveGetTask(store, 999)
mustEqual(err2, nil, "存在しないIDでもエラーは返さない(404は正常応答)")
mustEqual(status2, 404, "存在しないIDは404")
mustEqual(task2, Task{}, "存在しないIDはゼロ値のTask")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
- REST は「URLはリソース(名詞)」「HTTPメソッドは操作(動詞)」「ステータスコードは結果」を 一貫させる設計の約束事
- 404 は異常系ではなく正常なレスポンス。「見つからない」という結果を、エラーではなく ステータスコードで表現する
- 常に
200を返す API は、呼び出し側にボディの中身を毎回読ませる負担を強いる - GET・PUT・DELETE は冪等に、POST は非冪等(作成のたび新しいリソース)に設計する
答え合わせは 04.1-rest-design-solutions.ipynb で行ってください。
次は 04.2 で、実際に CRUD を一通り持つ REST API を Go で組み立てます。