← レッスン一覧に戻る

04.2 REST API を作る — encoding/json・CRUD・httptest.NewServer¶

04.1 では「良い REST 設計」と「悪い REST 設計」を実リクエストで比較しました。 このレッスンでは、実際に CRUD(Create/Read/Update/Delete)を一通り持つ REST API を Go の標準ライブラリだけで組み立てます。

このレッスンのゴール:

  • encoding/json でリクエスト/レスポンスの JSON をエンコード・デコードできる
  • http.NewServeMux() の メソッド + パスパラメータパターン("GET /tasks/{id}")でルーティングできる
  • httptest.NewServer で実サーバーを起動し、実際に HTTP リクエストを送って実レスポンスを確認できる
  • CRUD 各操作でどのステータスコードを選ぶべきかを、実例を通じて判断できる

このレッスンのデータ保存先は、あえて slice/map のインメモリ DAO です (database/sql は使いません)。REST/JSON/HTTP の意味論そのものに集中するためで、 「データアクセス層を実 DB に置き換える」話は次の 04.3 で扱います。

非自明ポイント1: WriteHeader は Write(Encode)より前に呼ぶ¶

http.ResponseWriter は「ヘッダーを書く」→「ボディを書く」の順が固定です。 json.NewEncoder(w).Encode(v) は内部で w.Write(...) を呼ぶため、Encode より前に w.WriteHeader(status) を呼ばないと、最初の Write 呼び出し時点で自動的に 200 OK が 確定してしまい、後から 404 等に変更できません。

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // ← 先に確定させる
json.NewEncoder(w).Encode(task)   // ← この Write でヘッダーが実際に送出される

非自明ポイント2: Go 1.22+ の ServeMux は「メソッド + パスパラメータ」を直接書ける¶

以前の Go では http.ServeMux はパスの前方一致しかできず、メソッド判定や /tasks/{id} のようなパラメータ抽出は自分で書く必要がありました。 Go 1.22 以降は標準ライブラリだけで次のように書けます。

mux.HandleFunc("GET /tasks/{id}", handler)   // メソッドとパスを1文字列で指定
id := r.PathValue("id")                       // {id} の実際の値を取得

サードパーティのルーターライブラリが担っていた仕事の一部を、標準ライブラリが肩代わりする ようになった例です(05章で学ぶ「フレームワークが何を肩代わりしているか」の伏線です)。

1. データアクセス層(インメモリ DAO)¶

Task(やること)を保持する TaskStore を作ります。複数リクエストが同時に来ても 安全なように sync.Mutex で保護します(httptest サーバーはゴルーチンでリクエストを 処理するため、排他制御が必要です)。

In [1]:
import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sort"
	"strconv"
	"strings"
	"sync"

	"github.com/janpfeifer/gonb/gonbui"
)

// Task は1件の「やること」。JSON タグでレスポンスのフィールド名を制御する。
type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

// TaskStore はインメモリの DAO(データアクセス層)。
// 実DBは使わず slice/map + sync.Mutex で永続化を模倣する。
type TaskStore struct {
	mu     sync.Mutex
	tasks  map[int]Task
	nextID int
}

func NewTaskStore() *TaskStore {
	return &TaskStore{tasks: make(map[int]Task), nextID: 1}
}

func (s *TaskStore) Create(title string) Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	task := Task{ID: s.nextID, Title: title, Done: false}
	s.tasks[task.ID] = task
	s.nextID++
	return task
}

func (s *TaskStore) Get(id int) (Task, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	task, ok := s.tasks[id]
	return task, ok
}

func (s *TaskStore) List() []Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		out = append(out, t)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out
}

func (s *TaskStore) Update(id int, title string, done bool) (Task, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	task, ok := s.tasks[id]
	if !ok {
		return Task{}, false
	}
	task.Title = title
	task.Done = done
	s.tasks[id] = task
	return task, true
}

func (s *TaskStore) Delete(id int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.tasks[id]; !ok {
		return false
	}
	delete(s.tasks, id)
	return true
}

2. ハンドラとルーティング¶

各 CRUD 操作を http.HandlerFunc として実装し、http.NewServeMux() に登録します。 store をクロージャで捕まえることで、ハンドラは「どの TaskStore を操作するか」を知っています。

In [2]:
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func handleCreateTask(store *TaskStore) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var input struct {
			Title string `json:"title"`
		}
		if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "不正なJSONです"})
			return
		}
		if input.Title == "" {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title は必須です"})
			return
		}
		task := store.Create(input.Title)
		writeJSON(w, http.StatusCreated, task) // 201 Created: 新規リソースを作った
	}
}

func handleListTasks(store *TaskStore) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, store.List())
	}
}

func handleGetTask(store *TaskStore) http.HandlerFunc {
	return 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": "id は数値である必要があります"})
			return
		}
		task, ok := store.Get(id)
		if !ok {
			writeJSON(w, http.StatusNotFound, map[string]string{"error": "タスクが見つかりません"})
			return
		}
		writeJSON(w, http.StatusOK, task)
	}
}

func handleUpdateTask(store *TaskStore) http.HandlerFunc {
	return 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": "id は数値である必要があります"})
			return
		}
		var input struct {
			Title string `json:"title"`
			Done  bool   `json:"done"`
		}
		if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "不正なJSONです"})
			return
		}
		task, ok := store.Update(id, input.Title, input.Done)
		if !ok {
			writeJSON(w, http.StatusNotFound, map[string]string{"error": "タスクが見つかりません"})
			return
		}
		writeJSON(w, http.StatusOK, task)
	}
}

func handleDeleteTask(store *TaskStore) http.HandlerFunc {
	return 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": "id は数値である必要があります"})
			return
		}
		if !store.Delete(id) {
			writeJSON(w, http.StatusNotFound, map[string]string{"error": "タスクが見つかりません"})
			return
		}
		w.WriteHeader(http.StatusNoContent) // 204 No Content: ボディなし
	}
}

// newTaskServer は新しい TaskStore を積んだ httptest サーバーを組み立てる。
// 呼び出し側が defer srv.Close() する。
func newTaskServer() *httptest.Server {
	store := NewTaskStore()
	mux := http.NewServeMux()
	mux.HandleFunc("POST /tasks", handleCreateTask(store))
	mux.HandleFunc("GET /tasks", handleListTasks(store))
	mux.HandleFunc("GET /tasks/{id}", handleGetTask(store))
	mux.HandleFunc("PUT /tasks/{id}", handleUpdateTask(store))
	mux.HandleFunc("DELETE /tasks/{id}", handleDeleteTask(store))
	return httptest.NewServer(mux)
}

// doRequest は実HTTPリクエストを送り、ステータスコードとレスポンスボディ文字列を返す。
func doRequest(method, url, body string) (int, string) {
	var reqBody io.Reader
	if body != "" {
		reqBody = strings.NewReader(body)
	}
	req, err := http.NewRequest(method, url, reqBody)
	if err != nil {
		panic(err)
	}
	if body != "" {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	respBody := string(b)
	if respBody == "" {
		respBody = "(空)"
	}
	return resp.StatusCode, respBody
}

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()
}

3. CRUD を実行してみる¶

サーバーを1つ起動し、同じサーバーに対して一連の CRUD 操作を実リクエストとして 順番に送ります。httptest.NewServer はローカルの空きポートで実際に待ち受けるので、 srv.URL は本物の http://127.0.0.1:xxxxx です。

In [3]:
%%
srv := newTaskServer()
defer srv.Close()

type opResult struct {
	op, method, path, reqBody string
	status                    int
	respBody                  string
}
var results []opResult

do := func(op, method, path, body string) {
	status, respBody := doRequest(method, srv.URL+path, body)
	results = append(results, opResult{op, method, path, body, status, respBody})
}

do("① CREATE", "POST", "/tasks", `{"title":"牛乳を買う"}`)
do("② CREATE", "POST", "/tasks", `{"title":"洗濯する"}`)
do("③ READ(一覧)", "GET", "/tasks", "")
do("④ READ(id=1)", "GET", "/tasks/1", "")
do("⑤ READ(id=999、存在しない)", "GET", "/tasks/999", "")
do("⑥ UPDATE(id=1 を完了に)", "PUT", "/tasks/1", `{"title":"牛乳を買う","done":true}`)
do("⑦ DELETE(id=1)", "DELETE", "/tasks/1", "")
do("⑧ READ(id=1、削除後)", "GET", "/tasks/1", "")
do("⑨ CREATE(不正なJSON)", "POST", "/tasks", `{"title":`)
do("⑩ READ(一覧、削除後)", "GET", "/tasks", "")

var rows [][]string
for _, r := range results {
	rows = append(rows, []string{
		r.op,
		fmt.Sprintf("%s %s", r.method, r.path),
		r.reqBody,
		fmt.Sprintf("%d", r.status),
		r.respBody,
	})
	fmt.Printf("%s: %s %s → status=%d body=%s\n", r.op, r.method, r.path, r.status, r.respBody)
}

gonbui.DisplayHTML(renderTable([]string{"操作", "メソッド パス", "リクエストBody", "ステータス", "レスポンスBody"}, rows))
gonbui.Sync()
① CREATE: POST /tasks → status=201 body={"id":1,"title":"牛乳を買う","done":false}

② CREATE: POST /tasks → status=201 body={"id":2,"title":"洗濯する","done":false}

③ READ(一覧): GET /tasks → status=200 body=[{"id":1,"title":"牛乳を買う","done":false},{"id":2,"title":"洗濯する","done":false}]

④ READ(id=1): GET /tasks/1 → status=200 body={"id":1,"title":"牛乳を買う","done":false}

⑤ READ(id=999、存在しない): GET /tasks/999 → status=404 body={"error":"タスクが見つかりません"}

⑥ UPDATE(id=1 を完了に): PUT /tasks/1 → status=200 body={"id":1,"title":"牛乳を買う","done":true}

⑦ DELETE(id=1): DELETE /tasks/1 → status=204 body=(空)
⑧ READ(id=1、削除後): GET /tasks/1 → status=404 body={"error":"タスクが見つかりません"}

⑨ CREATE(不正なJSON): POST /tasks → status=400 body={"error":"不正なJSONです"}

⑩ READ(一覧、削除後): GET /tasks → status=200 body=[{"id":2,"title":"洗濯する","done":false}]

操作メソッド パスリクエストBodyステータスレスポンスBody
① CREATEPOST /tasks{"title":"牛乳を買う"}201{"id":1,"title":"牛乳を買う","done":false}
② CREATEPOST /tasks{"title":"洗濯する"}201{"id":2,"title":"洗濯する","done":false}
③ READ(一覧)GET /tasks200[{"id":1,"title":"牛乳を買う","done":false},{"id":2,"title":"洗濯する","done":false}]
④ READ(id=1)GET /tasks/1200{"id":1,"title":"牛乳を買う","done":false}
⑤ READ(id=999、存在しない)GET /tasks/999404{"error":"タスクが見つかりません"}
⑥ UPDATE(id=1 を完了に)PUT /tasks/1{"title":"牛乳を買う","done":true}200{"id":1,"title":"牛乳を買う","done":true}
⑦ DELETE(id=1)DELETE /tasks/1204(空)
⑧ READ(id=1、削除後)GET /tasks/1404{"error":"タスクが見つかりません"}
⑨ CREATE(不正なJSON)POST /tasks{"title":400{"error":"不正なJSONです"}
⑩ READ(一覧、削除後)GET /tasks200[{"id":2,"title":"洗濯する","done":false}]

読んでください: ステータスコードとボディの対応を確認します。

  • ①② 201 Created — 新しいリソースを作った時。ボディには生成された id を含むタスク全体が返る
  • ③⑩ 200 OK — 一覧取得。③ は2件、⑥ の完了更新と ⑦ の削除を経た ⑩ は1件だけになる
  • ④ 200 OK — 単体取得。⑤ は同じ GET /tasks/{id} でも id が存在しないので 404 Not Found
  • ⑥ 200 OK — 更新後のタスク全体(done:true)が返る
  • ⑦ 204 No Content — 削除成功。ボディを持たないのが規約(成功したが返す情報が無い)
  • ⑧ 404 Not Found — ⑦ で削除済みの id を再度 GET したので見つからない
  • ⑨ 400 Bad Request — JSON が壊れているので、そもそも store.Create すら呼ばれない

同じ GET /tasks/{id} でも、id の存在有無でステータスコードが変わること、 削除は 204(ボディ無し)、それ以外の成功は 200/201(ボディ有り)という使い分けが、 実際のレスポンスとして目に見えます。

4. 直感・類推: 図書館の貸出カウンター¶

REST の CRUD 操作は、図書館のカウンターでのやり取りに似ています。

  • POST(作る)= 新刊を登録する。登録が終わると司書は「登録できました、番号は◯番です」(201)と番号付きの控えを返す
  • GET(読む)= 蔵書を照会する。あれば内容を教えてくれる(200)が、無ければ「その番号の本はありません」(404)とだけ言われる。何も壊れていない、ただ無いだけ
  • PUT(更新する)= 貸出カードを書き換える。書き換えた後の最新の状態を見せてくれる(200)
  • DELETE(消す)= 除籍する。除籍作業そのものは成功しても、もう返す本の情報が無いので、司書は「除籍しました」とだけ言って何も渡さない(204)

「操作が成功したかどうか」と「返す情報があるかどうか」は別の軸です。 削除の成功は情報が無いことこそが正しい結果なので、204 は「エラー」ではなく「成功、ただし ボディなし」を意味します。

練習問題 4.2: done でタスクを絞り込む¶

次のメソッドを実装してください。

// ListByDone は Done が done と一致するタスクだけを、id昇順で返す。
func (s *TaskStore) ListByDone(done bool) []Task

ヒント: s.tasks を全走査し、t.Done == done の行だけ集めてから、List() と同じように sort.Slice で ID 昇順に揃えてください(実際の REST API では、これは GET /tasks?done=true のようなクエリパラメータ絞り込みハンドラの内部で呼ばれる想定です)。

未実装のままだと nil を返します(これがこの演習の「未回答」センチネルです)。

In [4]:
// YOUR CODE HERE
func (s *TaskStore) ListByDone(done bool) []Task {
	return nil
}

チェックのためのヘルパー¶

In [5]:
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))
}

// seedTaskStoreForExercise はチェック用に既知の状態を持つ TaskStore を作る。
// HTTP サーバーは使わず、DAO を直接操作する(何度実行しても同じ結果になる)。
func seedTaskStoreForExercise() *TaskStore {
	s := NewTaskStore()
	s.Create("牛乳を買う")           // id=1, done=false
	s.Create("洗濯する")             // id=2, done=false
	t3 := s.Create("レポートを出す") // id=3
	s.Update(t3.ID, t3.Title, true)  // id=3, done=true
	return s
}
In [6]:
%%
store := seedTaskStoreForExercise()

doneTasks := store.ListByDone(true)
if doneTasks == nil {
	fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
	mustEqual(len(doneTasks), 1, "done=true のタスクは1件")
	mustEqual(doneTasks[0].Title, "レポートを出す", "done=true のタスクは「レポートを出す」")

	notDoneTasks := store.ListByDone(false)
	mustEqual(len(notDoneTasks), 2, "done=false のタスクは2件")
	mustEqual(notDoneTasks[0].Title, "牛乳を買う", "先頭は id 昇順で「牛乳を買う」")
	mustEqual(notDoneTasks[1].Title, "洗濯する", "2番目は「洗濯する」")

	fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください

まとめ¶

  • encoding/json の Encode/Decode で JSON とデータ構造を相互変換する。WriteHeader は Encode より前に呼ぶ
  • http.NewServeMux() の "METHOD /path/{param}" パターンと r.PathValue で、標準ライブラリだけでもルーティングとパスパラメータ抽出ができる
  • CRUD とステータスコードの対応: 201(作成)/ 200(取得・更新の成功、ボディあり)/ 204(削除の成功、ボディなし)/ 404(対象が無い)/ 400(リクエスト自体が不正)
  • 「操作が成功したか」と「返す情報があるか」は別の軸 — 204 はエラーではない
  • データアクセス層をインメモリにしたことで、REST/JSON/HTTP の設計そのものに集中できた(実DBへの置き換えは次のレッスン)

答え合わせは 04.2-build-rest-api-solutions.ipynb で行ってください。 次は 04.3 で、この API に3層アーキテクチャとDAO(データアクセス層)を導入し、実DBに繋ぎます。