← レッスン一覧に戻る

05.3 統合プロジェクト — DB・API・ミニフレームワークを組み合わせる¶

このコースの最後のレッスンです。Module 1〜4 で学んだ DB(02章・04.3)・API設計(04章)・ セキュリティ(04.4)と、05.2 で作った ミニフレームワーク(ルーティング・ミドルウェア)を 1つのタスク管理APIに統合します。

やり方は少し変わっています。「その概念を使わなかったらどう壊れるか」を先に実演し、 その後で正しい実装に直します。3つの段階を通します。

# 概念が無いと... 何が起きるか
① DB制約(UNIQUE)が無い 同じメールアドレスで重複登録できてしまう
② ルーティング(ServeMux)が無い 手書きの分岐(if/strings.HasPrefix)が意図しないパスを誤って処理する
③ ミドルウェアが無い ハンドラごとに認可チェックを書く必要があり、書き忘れが認可漏れになる
In [1]:
import (
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"

	"github.com/janpfeifer/gonb/gonbui"

	_ "modernc.org/sqlite"
)

// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")

// openCourseDB は、このコース共通の SQLite 接続を開く(1セル完結で使う。04.3 と同じ契約)。
func openCourseDB(path string) *sql.DB {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		panic(err)
	}
	db.SetMaxOpenConns(1)
	return db
}

const dbPath = "file:_05_3_integration.db"

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

① DB制約が無いと — 重複登録¶

04.3 で学んだ openCourseDB 契約(1セル完結・固定ファイル名・冪等シード)をそのまま使います。 まず email に 制約を付けずに users テーブルを作って同じメールアドレスで2回登録し(before)、 続けて同じ接続の中で email に UNIQUE を付けたテーブルへ作り直して同じ操作を行います(after)。 GoNB は %% セルをまたいでローカル変数を持ち越せないため、before/after の比較は 1つのセルの中で完結させます。

In [2]:
%%
db := openCourseDB(dbPath)
defer db.Close()

// --- before: UNIQUE制約なし ---
db.Exec(`DROP TABLE IF EXISTS users`)
db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT, password_hash TEXT)`)

db.Exec(`INSERT INTO users (email, password_hash) VALUES (?, ?)`, "taro@example.com", "dummy-hash-1")
db.Exec(`INSERT INTO users (email, password_hash) VALUES (?, ?)`, "taro@example.com", "dummy-hash-2")

var countNoConstraint int
db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = ?`, "taro@example.com").Scan(&countNoConstraint)
fmt.Printf("① before(UNIQUE制約なし): taro@example.com の登録件数 = %d 件(同じメールで重複登録できてしまう)\n", countNoConstraint)

// --- after: UNIQUE制約あり(同じ接続でテーブルを作り直す) ---
db.Exec(`DROP TABLE IF EXISTS users`)
db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, password_hash TEXT)`)

_, err1 := db.Exec(`INSERT INTO users (email, password_hash) VALUES (?, ?)`, "taro@example.com", "dummy-hash-1")
_, err2 := db.Exec(`INSERT INTO users (email, password_hash) VALUES (?, ?)`, "taro@example.com", "dummy-hash-2")

var countWithConstraint int
db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = ?`, "taro@example.com").Scan(&countWithConstraint)
fmt.Printf("① after(UNIQUE制約あり): 1回目の登録 err=%v / 2回目の登録 err=%v\n", err1, err2)
fmt.Printf("① after(UNIQUE制約あり): taro@example.com の登録件数 = %d 件\n", countWithConstraint)

gonbui.DisplayHTML(renderRows(
	[]string{"段階", "登録試行", "結果"},
	[][]string{
		{"① before(制約なし)", "同じメールで2回INSERT", fmt.Sprintf("%d件(重複登録が成立してしまう)", countNoConstraint)},
		{"① after(UNIQUE制約)", "同じメールで2回INSERT", fmt.Sprintf("%d件(2回目は %v で拒否)", countWithConstraint, err2 != nil)},
	},
))
gonbui.Sync()
① before(UNIQUE制約なし): taro@example.com の登録件数 = 2 件(同じメールで重複登録できてしまう)
① after(UNIQUE制約あり): 1回目の登録 err=<nil> / 2回目の登録 err=constraint failed: UNIQUE constraint failed: users.email (2067)
① after(UNIQUE制約あり): taro@example.com の登録件数 = 1 件
段階登録試行結果
① before(制約なし)同じメールで2回INSERT2件(重複登録が成立してしまう)
① after(UNIQUE制約)同じメールで2回INSERT1件(2回目は true で拒否)

観察点: アプリ側のロジック(Go のコード)は一切変えていません。DB側の制約(UNIQUE)だけが、 「同じメールアドレスは1件まで」というルールをアプリのバグに関わらず強制しています。 アプリ側の検証だけに頼ると、検証コードに漏れがあった瞬間にこのルールは崩れます。

② ルーティングが無いと — 意図しないパスの誤処理¶

GET /tasks/{id} と GET /tasks/export(一覧をまとめて返すエンドポイント)の2つを手書きの if/strings.HasPrefix で振り分けます。

In [3]:
var demoTasks = map[int]string{1: "牛乳を買う", 2: "資料作成"}

// manualRouter は ServeMux を使わず、手書きの分岐でパスを振り分ける("before" の実装)。
func manualRouter(method, path string) (int, string) {
	// /tasks/{id} 用の分岐を先に書いてしまった、というよくある実装順序。
	if method == "GET" && strings.HasPrefix(path, "/tasks/") {
		idStr := strings.TrimPrefix(path, "/tasks/")
		id, err := strconv.Atoi(idStr)
		if err != nil {
			// "/tasks/export" もここに来てしまう。"export" は数値に変換できないのでエラー扱いになる。
			return http.StatusInternalServerError, fmt.Sprintf(`{"error":"invalid id: %s"}`, idStr)
		}
		title, ok := demoTasks[id]
		if !ok {
			return http.StatusNotFound, `{"error":"not found"}`
		}
		return http.StatusOK, fmt.Sprintf(`{"id":%d,"title":%q}`, id, title)
	}
	// エクスポート用の分岐。上の分岐に "/tasks/export" を奪われるため、実際にはここへ到達しない。
	if method == "GET" && path == "/tasks/export" {
		return http.StatusOK, `{"exported":true,"count":2}`
	}
	return http.StatusNotFound, `{"error":"no route"}`
}

manualRouter を実行すると、「エクスポート機能を実装したのに、/tasks/{id} の分岐に先取りされて 500 エラーになる」というバグが起きます。/tasks/ というプレフィックス一致を先に書いたせいで、 /tasks/export がそちらに吸い込まれてしまうのです。続けて、http.NewServeMux() で同じ2つの ルートを登録し直します。Go 1.22 以降の ServeMux は、より具体的な(固定文字列の)パターンを、 {id} のようなワイルドカードより優先するという規則を持っています。 この比較を1つのセルの中で完結させます(GoNB はセルをまたいでローカル変数を持ち越せません)。

In [4]:
%%
// --- before: 手書きルーティング ---
statusBefore, bodyBefore := manualRouter("GET", "/tasks/export")
fmt.Printf("② before(手書きルーティング): GET /tasks/export → %d %s\n", statusBefore, bodyBefore)

// --- after: http.NewServeMux() ---
mux := http.NewServeMux()

mux.HandleFunc("GET /tasks/export", func(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{"exported": true, "count": len(demoTasks)})
})
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
	}
	title, ok := demoTasks[id]
	if !ok {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"id": id, "title": title})
})

srv := httptest.NewServer(mux)
defer srv.Close()

respExport, err := http.Get(srv.URL + "/tasks/export")
if err != nil {
	panic(err)
}
defer respExport.Body.Close()
bodyExport, _ := io.ReadAll(respExport.Body)

respID, err := http.Get(srv.URL + "/tasks/1")
if err != nil {
	panic(err)
}
defer respID.Body.Close()
bodyID, _ := io.ReadAll(respID.Body)

fmt.Printf("② after(ServeMux): GET /tasks/export → %d %s\n", respExport.StatusCode, strings.TrimSpace(string(bodyExport)))
fmt.Printf("② after(ServeMux): GET /tasks/1      → %d %s\n", respID.StatusCode, strings.TrimSpace(string(bodyID)))

gonbui.DisplayHTML(renderRows(
	[]string{"段階", "リクエスト", "結果"},
	[][]string{
		{"② before(手書き)", "GET /tasks/export", fmt.Sprintf("%d %s", statusBefore, bodyBefore)},
		{"② after(ServeMux)", "GET /tasks/export", fmt.Sprintf("%d %s", respExport.StatusCode, strings.TrimSpace(string(bodyExport)))},
		{"② after(ServeMux)", "GET /tasks/1", fmt.Sprintf("%d %s", respID.StatusCode, strings.TrimSpace(string(bodyID)))},
	},
))
gonbui.Sync()
② before(手書きルーティング): GET /tasks/export → 500 {"error":"invalid id: export"}
② after(ServeMux): GET /tasks/export → 200 {"count":2,"exported":true}
② after(ServeMux): GET /tasks/1      → 200 {"id":1,"title":"牛乳を買う"}
段階リクエスト結果
② before(手書き)GET /tasks/export500 {"error":"invalid id: export"}
② after(ServeMux)GET /tasks/export200 {"count":2,"exported":true}
② after(ServeMux)GET /tasks/1200 {"id":1,"title":"牛乳を買う"}

観察点: manualRouter と ServeMux は登録した順番も内容もほぼ同じなのに、結果が違います。 ServeMux は「どちらのパターンがより具体的か」を自分で判断してくれるため、登録順を気にせずに 正しく振り分けられます。手書きの分岐では、この優先順位を自分で正しく並べる責任が実装者に残ります。

③ ミドルウェアが無いと — 認可漏れ¶

GET /tasks/{id} は認可チェックを書き、DELETE /tasks/{id} は書き忘れた、という状況を再現します (05.2 と同じ Middleware の型を使います。GoNB はローカルパッケージを import できないため、 ここに同じ定義をインライン複製します)。

In [5]:
// Middleware は「ハンドラを受け取り、別のハンドラを返す」関数(05.2 と同じ定義)。
type Middleware func(http.HandlerFunc) http.HandlerFunc

const validToken = "Bearer secret-token"

// --- ③ before: ハンドラごとに個別に認可チェックを書く ---

func getTaskHandlerNoMW(w http.ResponseWriter, r *http.Request) {
	if r.Header.Get("Authorization") != validToken {
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
		return
	}
	id, _ := strconv.Atoi(r.PathValue("id"))
	writeJSON(w, http.StatusOK, map[string]any{"id": id, "title": demoTasks[id]})
}

// deleteTaskHandlerNoMW は「認可チェックを書き忘れた」ハンドラ。レビューで見逃されたと想定する。
func deleteTaskHandlerNoMW(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.Atoi(r.PathValue("id"))
	delete(demoTasks, id)
	writeJSON(w, http.StatusOK, map[string]string{"result": "deleted"})
}

// --- ③ after: すべてのルートに一律で通す認可ミドルウェア ---

func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") != validToken {
			writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
			return
		}
		next(w, r)
	}
}

この比較も1つのセルの中で完結させます。before/after それぞれ専用の httptest.NewServer を 起動し、demoTasks はそれぞれの検証の直前でリセットしてから使います。

In [6]:
%%
// --- ③ before: ハンドラごとに個別に認可チェック(DELETEは書き忘れ) ---
demoTasks = map[int]string{1: "牛乳を買う", 2: "資料作成"}

muxBefore := http.NewServeMux()
muxBefore.HandleFunc("GET /tasks/{id}", getTaskHandlerNoMW)
muxBefore.HandleFunc("DELETE /tasks/{id}", deleteTaskHandlerNoMW) // 認可チェック忘れ

srvBefore := httptest.NewServer(muxBefore)
defer srvBefore.Close()

// トークン無しで削除を試みる(本来は拒否されるべき)。
reqDel, _ := http.NewRequest("DELETE", srvBefore.URL+"/tasks/1", nil)
respDelBefore, err := http.DefaultClient.Do(reqDel)
if err != nil {
	panic(err)
}
defer respDelBefore.Body.Close()
bodyDelBefore, _ := io.ReadAll(respDelBefore.Body)

_, stillExists := demoTasks[1]
fmt.Printf("③ before(認可チェック個別実装): トークン無しで DELETE /tasks/1 → %d %s\n", respDelBefore.StatusCode, strings.TrimSpace(string(bodyDelBefore)))
fmt.Printf("③ before: 削除後もタスク1は存在するか? → %v(本来は削除されてはいけない)\n", stillExists)

// --- ③ after: すべてのルートに一律で通す認可ミドルウェア ---
demoTasks = map[int]string{1: "牛乳を買う", 2: "資料作成"}

muxAfter := http.NewServeMux()
// authMiddleware をすべてのハンドラに一律で通す。新しいルートを追加しても、この登録の仕方を
// 踏襲する限り認可チェックを書き忘れられない。
muxAfter.HandleFunc("GET /tasks/{id}", authMiddleware(getTaskHandlerNoMW))
muxAfter.HandleFunc("DELETE /tasks/{id}", authMiddleware(deleteTaskHandlerNoMW))

srvAfter := httptest.NewServer(muxAfter)
defer srvAfter.Close()

reqDel2, _ := http.NewRequest("DELETE", srvAfter.URL+"/tasks/1", nil)
respDelAfter, err := http.DefaultClient.Do(reqDel2)
if err != nil {
	panic(err)
}
defer respDelAfter.Body.Close()
bodyDelAfter, _ := io.ReadAll(respDelAfter.Body)

_, stillExistsAfter := demoTasks[1]
fmt.Printf("③ after(ミドルウェア一律適用): トークン無しで DELETE /tasks/1 → %d %s\n", respDelAfter.StatusCode, strings.TrimSpace(string(bodyDelAfter)))
fmt.Printf("③ after: 削除後もタスク1は存在するか? → %v(正しく拒否され、削除されない)\n", stillExistsAfter)

gonbui.DisplayHTML(renderRows(
	[]string{"段階", "リクエスト", "結果", "タスクは残ったか"},
	[][]string{
		{"③ before(個別実装)", "トークン無しで DELETE /tasks/1", fmt.Sprintf("%d %s", respDelBefore.StatusCode, strings.TrimSpace(string(bodyDelBefore))), fmt.Sprintf("%v", stillExists)},
		{"③ after(ミドルウェア)", "トークン無しで DELETE /tasks/1", fmt.Sprintf("%d %s", respDelAfter.StatusCode, strings.TrimSpace(string(bodyDelAfter))), fmt.Sprintf("%v", stillExistsAfter)},
	},
))
gonbui.Sync()
③ before(認可チェック個別実装): トークン無しで DELETE /tasks/1 → 200 {"result":"deleted"}
③ before: 削除後もタスク1は存在するか? → false(本来は削除されてはいけない)
③ after(ミドルウェア一律適用): トークン無しで DELETE /tasks/1 → 401 {"error":"unauthorized"}
③ after: 削除後もタスク1は存在するか? → true(正しく拒否され、削除されない)
段階リクエスト結果タスクは残ったか
③ before(個別実装)トークン無しで DELETE /tasks/1200 {"result":"deleted"}false
③ after(ミドルウェア)トークン無しで DELETE /tasks/1401 {"error":"unauthorized"}true

観察点: deleteTaskHandlerNoMW 自体のコードは③の前後で一切変えていません。 before では認可チェックがハンドラの中に無いため、トークン無しの削除リクエストが成功してしまい (200、タスクが実際に消える)、after ではミドルウェアがハンドラの外側で一律にブロックするため、 同じハンドラでも安全に守られます。「各ハンドラが自分で気をつける」より「入口で一律に強制する」方が 書き忘れに強い、というのがミドルウェアの価値です。

直感・類推: 建物のセキュリティ設計¶

  • ① DB制約 = 金庫そのものの構造(同じ鍵穴が2つ作れない)。誰が鍵を作っても物理的に不可能
  • ② ルーティング = 番地順に並んだ案内板。手書きのメモ(前から順に読んで最初に一致した案内へ進む) だと、後から追加した案内が古いメモに紛れて読まれないことがある
  • ③ ミドルウェア = 建物の正面玄関の警備員。各部屋のドアに鍵を付け忘れても、 そもそも建物に入る時点で身分証チェックがあれば、その部屋にはたどり着けない

3つとも共通しているのは、「アプリケーションコード(ハンドラの中身)の正しさ」に頼らず、 外側の仕組み(DB・ルーター・ミドルウェア)でルールを構造的に強制しているという点です。

練習問題 5.3: classifyRegistrationError を実装しよう¶

①のUNIQUE制約違反を、API層で分かりやすい409 Conflictとして返せるようにする第一歩として、 「DBのエラーを、API層で使える意味のあるエラーに変換する」関数を実装してください。

仕様:

// classifyRegistrationError は、ユーザー登録時のDBエラーを分類する。
func classifyRegistrationError(err error) error
  • err が nil なら nil を返す(成功、変換の必要なし)
  • err.Error() に "UNIQUE constraint failed" という文字列が含まれていれば、 errors.New("email already registered") を返す(重複登録という意味のあるエラーに変換する)
  • それ以外のエラーは、そのまま err を返す(握りつぶさず透過させる)
In [7]:
// YOUR CODE HERE
// classifyRegistrationError を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func classifyRegistrationError(err error) error {
	return ErrUnanswered
}

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

In [8]:
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))
}
In [9]:
%%
result1 := classifyRegistrationError(nil)
if errors.Is(result1, ErrUnanswered) {
	fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
	mustEqual(result1, nil, "nilエラーはnilのまま")

	otherErr := errors.New("some other error")
	mustEqual(classifyRegistrationError(otherErr), otherErr, "無関係なエラーはそのまま透過する")

	dupSourceErr := errors.New("UNIQUE constraint failed: users.email")
	classified := classifyRegistrationError(dupSourceErr)
	if classified == nil || classified.Error() != "email already registered" {
		panic(fmt.Sprintf("❌ UNIQUE制約違反は email already registered に変換されるはず(got=%v)", classified))
	}
	fmt.Println("✅ Passed: UNIQUE制約違反は email already registered に変換される")

	// 実際にDBへ重複INSERTして、本物のsqliteエラーでも判定できるか確認する。
	db := openCourseDB("file:_05_3_exercise_check.db")
	defer db.Close()
	db.Exec(`DROP TABLE IF EXISTS exercise_users`)
	db.Exec(`CREATE TABLE exercise_users (id INTEGER PRIMARY KEY, email TEXT UNIQUE)`)
	db.Exec(`INSERT INTO exercise_users (email) VALUES (?)`, "hanako@example.com")
	_, dupErr := db.Exec(`INSERT INTO exercise_users (email) VALUES (?)`, "hanako@example.com")

	realClassified := classifyRegistrationError(dupErr)
	if realClassified == nil || realClassified.Error() != "email already registered" {
		panic(fmt.Sprintf("❌ 実際のsqliteエラーも変換されるはず(got=%v)", realClassified))
	}
	fmt.Println("✅ Passed: 実際のsqlite UNIQUE制約違反も email already registered に変換される")

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

まとめ¶

  • DB制約(UNIQUE)は、アプリのコードが正しいかどうかに関わらず、データの整合性を構造的に守る
  • ルーティング(ServeMux)は、手書きの分岐よりも優先順位のルールが一貫しているため、 ルートの追加順に振る舞いが左右されにくい
  • ミドルウェアは、ハンドラごとに同じチェックを書く責任を無くし、書き忘れという人為的ミスの 影響範囲を構造的に小さくする
  • 3つとも「実装者の注意力」ではなく「仕組み」でルールを強制する、という共通の考え方に基づいている

Module 5(フレームワークと自作)、そしてこのコース全体(OOP → データベース → ネットワーク/サーバー → API設計 → フレームワーク)はこれで完結です。お疲れさまでした。