← レッスン一覧に戻る

解答 04.2 — REST API を作る(ListByDone)¶

このノートブックは 04.2-build-rest-api.ipynb の練習問題の解答です。

先に自分の力で解いてから、答え合わせに使ってください。

前提コード(問題ノートブックと同じ定義)¶

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
}

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

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

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

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

CRUD デモ(問題ノートブックと同じ実行)¶

In [2]:
%%
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}]

解答: ListByDone¶

s.tasks を全走査し、Done が一致する行だけを集めてから ID 昇順に並べ替えます。 List() とほぼ同じ実装ですが、フィルタ条件が1つ加わっただけです。

In [3]:
func (s *TaskStore) ListByDone(done bool) []Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	var out []Task
	for _, t := range s.tasks {
		if t.Done == done {
			out = append(out, t)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out
}

チェック(問題ノートブックと同じ期待値)¶

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

func seedTaskStoreForExercise() *TaskStore {
	s := NewTaskStore()
	s.Create("牛乳を買う")
	s.Create("洗濯する")
	t3 := s.Create("レポートを出す")
	s.Update(t3.ID, t3.Title, true)
	return s
}
In [5]:
%%
store := seedTaskStoreForExercise()

doneTasks := store.ListByDone(true)
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("🎉 すべてのチェックが通りました")
✅ Passed: done=true のタスクは1件
✅ Passed: done=true のタスクは「レポートを出す」
✅ Passed: done=false のタスクは2件
✅ Passed: 先頭は id 昇順で「牛乳を買う」
✅ Passed: 2番目は「洗濯する」
🎉 すべてのチェックが通りました

解説¶

  1. for _, t := range s.tasks { if t.Done == done { ... } } — マップの走査順は保証されないため、まず条件に合う行だけを集める
  2. sort.Slice(out, ...) — List() と同じ並べ替えロジックを使い、呼び出すたびに同じ順序(id昇順)を保証する。REST API の一覧系エンドポイントは「同じ状態に対しては同じ順序で返す」ことが重要(呼び出すたびに順序が変わるとクライアント側のテストやページングが壊れる)
  3. 未回答時に nil を返すセンチネル — 実装済みなら sort.Slice が空スライスではなく必ず要素を持つ out を返す(このチェックのシードデータには done=true が1件ある)ので、nil との違いで判定できる

ListByDone は store.List() と同じ DAO 層のメソッドです。実際の REST API なら、 GET /tasks?done=true のハンドラが r.URL.Query().Get("done") を読み取ってこのメソッドを 呼ぶ形で配線されます(配線自体はこの演習の範囲外です)。