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 サーバーはゴルーチンでリクエストを
処理するため、排他制御が必要です)。
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 を操作するか」を知っています。
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 です。
%%
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 |
|---|---|---|---|---|
| ① CREATE | POST /tasks | {"title":"牛乳を買う"} | 201 | {"id":1,"title":"牛乳を買う","done":false} |
| ② CREATE | POST /tasks | {"title":"洗濯する"} | 201 | {"id":2,"title":"洗濯する","done":false} |
| ③ READ(一覧) | GET /tasks | 200 | [{"id":1,"title":"牛乳を買う","done":false},{"id":2,"title":"洗濯する","done":false}] | |
| ④ READ(id=1) | GET /tasks/1 | 200 | {"id":1,"title":"牛乳を買う","done":false} | |
| ⑤ READ(id=999、存在しない) | GET /tasks/999 | 404 | {"error":"タスクが見つかりません"} | |
| ⑥ UPDATE(id=1 を完了に) | PUT /tasks/1 | {"title":"牛乳を買う","done":true} | 200 | {"id":1,"title":"牛乳を買う","done":true} |
| ⑦ DELETE(id=1) | DELETE /tasks/1 | 204 | (空) | |
| ⑧ READ(id=1、削除後) | GET /tasks/1 | 404 | {"error":"タスクが見つかりません"} | |
| ⑨ CREATE(不正なJSON) | POST /tasks | {"title": | 400 | {"error":"不正なJSONです"} |
| ⑩ READ(一覧、削除後) | GET /tasks | 200 | [{"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 を返します(これがこの演習の「未回答」センチネルです)。
// YOUR CODE HERE
func (s *TaskStore) ListByDone(done bool) []Task {
return nil
}
チェックのためのヘルパー¶
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
}
%%
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に繋ぎます。