解答 05.2 — 自作ミニWebフレームワーク(validateTitle)¶
このノートブックは 05.2-build-mini-framework.ipynb の練習問題の解答です。
先に自分の力で解いてから、答え合わせに使ってください。
前提コード(問題ノートブックと同じ定義)¶
In [1]:
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
解答: validateTitle¶
トリム後の文字数が0なら必須エラー、50を超えていたら文字数エラーを返します。
日本語を含む可能性があるため、文字数は len([]rune(...))(ルーン数)で数えます。
In [2]:
func validateTitle(title string) error {
trimmed := strings.TrimSpace(title)
if trimmed == "" {
return errors.New("title は必須です")
}
if len([]rune(trimmed)) > 50 {
return errors.New("title は50文字以内にしてください")
}
return nil
}
チェック(問題ノートブックと同じ期待値)¶
In [3]:
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 [4]:
%%
err1 := validateTitle("")
if err1 == nil || err1.Error() != "title は必須です" {
panic(fmt.Sprintf("❌ 空文字は必須エラーになるはず(got err=%v)", err1))
}
fmt.Println("✅ Passed: 空文字は「title は必須です」")
long := strings.Repeat("あ", 51)
err2 := validateTitle(long)
if err2 == nil || err2.Error() != "title は50文字以内にしてください" {
panic(fmt.Sprintf("❌ 51文字は文字数エラーになるはず(got err=%v)", err2))
}
fmt.Println("✅ Passed: 51文字は「title は50文字以内にしてください」")
mustEqual(validateTitle("資料作成"), nil, "妥当な title はエラーなし")
// 実際に1本の小さなハンドラへ組み込み、本物のHTTPレスポンスとしても確認する。
checkMux := http.NewServeMux()
checkMux.HandleFunc("POST /check-title", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if verr := validateTitle(string(body)); verr != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": verr.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"result": "ok"})
})
checkSrv := httptest.NewServer(checkMux)
defer checkSrv.Close()
resp, err := http.Post(checkSrv.URL+"/check-title", "text/plain", strings.NewReader(strings.Repeat("い", 60)))
if err != nil {
panic(err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
mustEqual(resp.StatusCode, 422, "60文字のtitleは実際のHTTPレスポンスでも422")
mustEqual(strings.Contains(string(respBody), "50文字以内"), true, "エラーメッセージがレスポンスボディに含まれる")
fmt.Println("🎉 すべてのチェックが通りました")
✅ Passed: 空文字は「title は必須です」 ✅ Passed: 51文字は「title は50文字以内にしてください」 ✅ Passed: 妥当な title はエラーなし
✅ Passed: 60文字のtitleは実際のHTTPレスポンスでも422 ✅ Passed: エラーメッセージがレスポンスボディに含まれる 🎉 すべてのチェックが通りました
解説¶
strings.TrimSpaceを先に行う — 空白だけの入力(" ")を「必須」エラーとして扱うため、 トリム後の文字列で判定しますlen([]rune(...))でルーン数を数える — Goのlen(string)はバイト数を返すため、日本語の ような複数バイト文字を含む文字列では見た目の文字数と一致しません。[]runeに変換してからlenを取ることで、正しい文字数で判定できます- チェックセルの後半 —
validateTitleを実際に小さなハンドラへ組み込み、httptest.NewServerで本物の HTTP リクエストを送って確かめています。純粋関数としての正しさだけでなく、 「実際に422レスポンスとしてエラー内容が返るか」まで検証しています
validateTitle は本編の validateCreateTaskRequest(title・priority の両方を検証)のうち、
title 単体のルールだけを切り出したものです。実際のバリデーション関数は、このようにフィールドごとの
検証を組み合わせて作られることが多くあります。