解答 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 |
|---|---|---|---|---|
| ① 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}] |
解答: 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番目は「洗濯する」 🎉 すべてのチェックが通りました
解説¶
for _, t := range s.tasks { if t.Done == done { ... } }— マップの走査順は保証されないため、まず条件に合う行だけを集めるsort.Slice(out, ...)—List()と同じ並べ替えロジックを使い、呼び出すたびに同じ順序(id昇順)を保証する。REST API の一覧系エンドポイントは「同じ状態に対しては同じ順序で返す」ことが重要(呼び出すたびに順序が変わるとクライアント側のテストやページングが壊れる)- 未回答時に
nilを返すセンチネル — 実装済みならsort.Sliceが空スライスではなく必ず要素を持つoutを返す(このチェックのシードデータにはdone=trueが1件ある)ので、nilとの違いで判定できる
ListByDone は store.List() と同じ DAO 層のメソッドです。実際の REST API なら、
GET /tasks?done=true のハンドラが r.URL.Query().Get("done") を読み取ってこのメソッドを
呼ぶ形で配線されます(配線自体はこの演習の範囲外です)。