2-7. REST API 統合 - 実務OKレベルの TODO API を組む

所要時間: 60-90分(3セッション分、Level 2 の総決算) コミット内容: ~/learn/go/level2/day07/ に完成形の TODO API


このレッスンのゴール

  • go-playground/validator でフィールド検証を組める
  • RFC 7807 風(Problem+JSON)のエラーレスポンスを返せる
  • HTTP ステータスコード(200/201/204/400/404/409/422/500)を意味通り使い分ける
  • ページネーション(上限付き)を実装
  • slog で構造化ロギング、リクエスト ID 串刺し
  • SIGTERM での Graceful Shutdown を実装

なぜ学ぶか - Level 2 の総決算として

本番投入できる API」と「動くだけの API」を分けるのは、ここで扱う6項目: バリデーション・エラー形式・ステータスコード・ページネーション・ロギング・シャットダウン。これらが揃って初めて デプロイで落ちない、障害時に追跡できる、過負荷で死なない API になる。Level 1~2 の全要素が1つのアプリに統合される集大成

前章とのつながり(間隔反復)

Level 2 全7章を1つのプロジェクトに統合 する。

これができると何が嬉しいか

  • 「本番投入チェックリスト」が脳内で読み上げられる - レビューで漏れを指摘できる
  • CRUD API を2-3時間で書ける - 新規プロジェクトの立ち上げが速い
  • Kubernetes での terminationGracePeriodSeconds と整合する shutdown が書ける
  • 422 vs 400 の使い分け で REST 設計レビューに耐える

ストーリー導入: 「動く」と「実務OK」の谷

「curl で動いた、よし完成」と思った3週間後、メモリリーク / Slowloris で停止 / panic でクラッシュ / 422返さずクライアント死亡 / SIGTERM で進行中リクエスト切断 などの本番障害ラッシュ。本章は その谷を埋める章。動くコード → 実務OK のコードへの 6つの追加要素 が学べる。


大前提: 「実務OK」とは何か

ここまでで個々のテーマは押さえた。最後にそれらを統合し、「本番に出して恥ずかしくない」レベルの API を組む。

「実務OK」の基準:

  1. 入力バリデーションが網羅されている(不正リクエストで落ちない)
  2. エラーレスポンスの形式が統一されている(クライアントが処理しやすい)
  3. 構造化ログで全リクエストの記録が残る(障害時に追跡可能)
  4. タイムアウトと graceful shutdown が設定されている(無限待ちなし、デプロイで落ちない)
  5. 適切な HTTP ステータスコードが返る(201、204、400、404、422、500 の使い分け)
  6. ページネーションが実装されている(10万件返してメモリ吹き飛ばないこと)

これが揃って初めて「本番投入できる API」と言える。


セッション①: バリデーションとエラーレスポンス(30分)

0. 録画スタート&作業ディレクトリ

mkdir -p ~/log ~/learn/go/level2/day07
cd ~/learn/go/level2/day07
script ~/log/go_level2_day07.log
 
# Go モジュール初期化(章本文で github.com/yourname/todo-api を想定)
go mod init github.com/yourname/todo-api
go get github.com/go-playground/validator/v10
go get github.com/google/uuid

ディレクトリ構造:

todo-api/
├── cmd/server/main.go
├── internal/
│   ├── httperr/       ← エラーレスポンス整形
│   ├── httpmw/        ← ミドルウェア群
│   └── todo/          ← ハンドラ・サービス・リポジトリ
└── go.mod

1. リクエストバリデーション

// internal/todo/request.go
package todo
 
import (
	"github.com/go-playground/validator/v10"
)
 
var validate = validator.New()
 
type CreateRequest struct {
	Title    string `json:"title" validate:"required,min=1,max=200"`
	Priority int    `json:"priority" validate:"min=0,max=5"`
	DueAt    string `json:"due_at,omitempty" validate:"omitempty,datetime=2006-01-02"`
}
 
type UpdateRequest struct {
	Title    *string `json:"title,omitempty" validate:"omitempty,min=1,max=200"`
	Done     *bool   `json:"done,omitempty"`
	Priority *int    `json:"priority,omitempty" validate:"omitempty,min=0,max=5"`
}
 
// バリデーション実行
func (r CreateRequest) Validate() error {
	return validate.Struct(r)
}
 
func (r UpdateRequest) Validate() error {
	return validate.Struct(r)
}

go-playground/validator のタグ仕様

タグ意味
required必須(ゼロ値だとエラー)
min=N文字列なら最小文字数、数値なら最小値
max=N同上の上限
emailメールアドレス形式
urlURL 形式
oneof=a b c列挙値
gte=N / lte=N数値の以上 / 以下
omitemptyゼロ値なら検証しない(PATCH 用)
datetime=YYYY-MM-DD指定フォーマットの日時

業界標準ライブラリ。gin / echo / chi のほぼ全てが内部で使っている。

PATCH リクエストにはポインタフィールド + omitempty

UpdateRequestTitle string にしてしまうと、「title を空文字に更新」と「title は更新しない」の区別ができない。

修正: *string 型 + omitempty バリデーションタグ。これで「nil = 未指定」「非 nil = この値で更新」を区別できる(2-3 JSON で学んだ通り)。

2. エラーレスポンス - RFC 7807 簡略版

// internal/httperr/httperr.go
package httperr
 
import (
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
 
	"github.com/go-playground/validator/v10"
)
 
// Problem は RFC 7807 application/problem+json に近い構造
type Problem struct {
	Type     string            `json:"type,omitempty"`     // エラーの種類 URI
	Title    string            `json:"title"`              // 短いタイトル
	Status   int               `json:"status"`             // HTTP ステータスコード
	Detail   string            `json:"detail,omitempty"`   // 詳細メッセージ
	Instance string            `json:"instance,omitempty"` // 発生したリクエストパス
	Errors   map[string]string `json:"errors,omitempty"`   // フィールド別エラー(バリデーション用)
}
 
// AppError - HTTP に変換可能なエラー
type AppError struct {
	Status  int
	Title   string
	Detail  string
	Cause   error
}
 
func (e *AppError) Error() string  { return e.Title + ": " + e.Detail }
func (e *AppError) Unwrap() error  { return e.Cause }
 
func NotFound(detail string) *AppError {
	return &AppError{Status: 404, Title: "Not Found", Detail: detail}
}
 
func BadRequest(detail string, cause error) *AppError {
	return &AppError{Status: 400, Title: "Bad Request", Detail: detail, Cause: cause}
}
 
func Conflict(detail string) *AppError {
	return &AppError{Status: 409, Title: "Conflict", Detail: detail}
}
 
func Internal(cause error) *AppError {
	return &AppError{Status: 500, Title: "Internal Server Error", Detail: "an internal error occurred", Cause: cause}
}
 
// Write は AppError / バリデーションエラー / その他を統一形式で返す
func Write(w http.ResponseWriter, r *http.Request, logger *slog.Logger, err error) {
	w.Header().Set("Content-Type", "application/problem+json")
 
	// バリデーションエラー
	var vErrs validator.ValidationErrors
	if errors.As(err, &vErrs) {
		fields := map[string]string{}
		for _, fe := range vErrs {
			fields[fe.Field()] = fe.Tag()
		}
		writeJSON(w, http.StatusUnprocessableEntity, Problem{
			Title:    "Validation Failed",
			Status:   422,
			Detail:   "request body has validation errors",
			Instance: r.URL.Path,
			Errors:   fields,
		})
		return
	}
 
	// AppError
	var appErr *AppError
	if errors.As(err, &appErr) {
		if appErr.Status >= 500 {
			logger.Error("server error",
				"err", err,
				"path", r.URL.Path,
				"cause", appErr.Cause,
			)
		}
		writeJSON(w, appErr.Status, Problem{
			Title:    appErr.Title,
			Status:   appErr.Status,
			Detail:   appErr.Detail,
			Instance: r.URL.Path,
		})
		return
	}
 
	// その他(想定外)
	logger.Error("unhandled error", "err", err, "path", r.URL.Path)
	writeJSON(w, 500, Problem{
		Title:    "Internal Server Error",
		Status:   500,
		Detail:   "an unexpected error occurred",
		Instance: r.URL.Path,
	})
}
 
func writeJSON(w http.ResponseWriter, status int, body any) {
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(body)
}

RFC 7807 (problem+json) とは

エラーレスポンスの 標準フォーマットContent-Type: application/problem+json で返す。

採用例:

  • GitHub API のエラー(一部)
  • Microsoft Graph API
  • Spring Boot のデフォルト

良さ:

  • クライアントが共通の構造でエラーをハンドリングできる
  • フィールド別エラー、エラーカタログ URL を一貫した方法で提供

ただし完全準拠は仰々しいので、実務では「Title, Status, Detail + フィールドエラー」程度の簡略版が普及

3. HTTP ステータスコード使い分け

よく使うステータスコード

コード意味用途
200 OK成功GET、PUT(更新成功)、DELETE(削除成功でボディあり)
201 Created作成成功POST で新リソース作成
204 No Content成功、ボディなしDELETE、PUT(ボディ返さない時)
400 Bad Request構文エラーJSON パース失敗、Content-Type 不一致
401 Unauthorized認証必要トークン無し、無効
403 Forbidden権限なし認証はOKだが操作不許可
404 Not FoundリソースなしID が存在しない
409 Conflict競合重複登録、楽観ロック失敗
422 Unprocessable Entity意味的エラーバリデーション失敗(構文は正しいが内容がダメ)
429 Too Many Requestsレート制限スロットリング
500 Internal Server Errorサーバー側バグ想定外の panic、DB 障害
502 / 503 / 504上流障害バックエンド到達不可、過負荷、タイムアウト

アンチパターン: 全部 200 を返す

// NG: エラーでも 200 で返す
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]any{
    "success": false,
    "error": "user not found",
})

なぜNG:

  • クライアントが「成功 / 失敗」を ボディの中身まで読まないと判断できない
  • HTTP の意味論を無視している → CDN、ロードバランサ、監視ツールが正常扱いしてしまう
  • エラーレートメトリクスが取れない(全部 2xx)

修正: HTTP ステータスは HTTP の意味通りに使う。「正常レスポンスかエラーか」はステータスコードで表現。

4. 400 vs 422 の使い分け

// 400 Bad Request: 構文がそもそもおかしい
// → JSON パース失敗、必須フィールド欠落(型レベル)
 
// 422 Unprocessable Entity: 構文は正しいが意味がおかしい
// → バリデーション失敗(title が長すぎる、priority が負)

細かい違いだが意外と重要

  • 400: 「リクエストとして読めない」レベル。JSON 壊れている、Content-Type 違う
  • 422: 「読めたけどルール違反」。フィールド検証エラー、ビジネスルール違反

GitHub API、Stripe API などはこの区別を採用。go-playground/validator のエラーは 422 で返すのが綺麗。


セッション②: 構造化ロギングと TODO API 完成(30分)

5. slog によるリクエストログ

// internal/httpmw/middleware.go
package httpmw
 
import (
	"context"
	"log/slog"
	"net/http"
	"runtime/debug"
	"time"
 
	"github.com/google/uuid"
)
 
type ctxKey string
 
const (
	requestIDKey ctxKey = "request_id"
	loggerKey    ctxKey = "logger"
)
 
func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = uuid.NewString()
		}
		w.Header().Set("X-Request-ID", id)
		ctx := context.WithValue(r.Context(), requestIDKey, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}
 
func InjectLogger(base *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rid, _ := r.Context().Value(requestIDKey).(string)
			logger := base.With("request_id", rid)
			ctx := context.WithValue(r.Context(), loggerKey, logger)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}
 
func LoggerFromCtx(ctx context.Context) *slog.Logger {
	if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
		return l
	}
	return slog.Default()
}
 
type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}
 
func (s *statusRecorder) WriteHeader(c int) { s.status = c; s.ResponseWriter.WriteHeader(c) }
func (s *statusRecorder) Write(b []byte) (int, error) {
	if s.status == 0 { s.status = 200 }
	n, err := s.ResponseWriter.Write(b)
	s.bytes += n
	return n, err
}
 
func AccessLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w}
		next.ServeHTTP(rec, r)
 
		logger := LoggerFromCtx(r.Context())
		logger.Info("http",
			"method", r.Method,
			"path", r.URL.Path,
			"status", rec.status,
			"bytes", rec.bytes,
			"duration_ms", time.Since(start).Milliseconds(),
			"remote", r.RemoteAddr,
			"ua", r.UserAgent(),
		)
	})
}
 
func Recover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				logger := LoggerFromCtx(r.Context())
				logger.Error("panic recovered",
					"err", rec,
					"stack", string(debug.Stack()),
					"path", r.URL.Path,
				)
				http.Error(w, `{"title":"Internal Server Error","status":500}`, 500)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

log/slog (Go 1.21+) は標準の構造化ロガー

slog は Go 1.21 で標準入りした構造化ロギング。それまでは logrus zap zerolog が群雄割拠だった。

標準の強み:

  • 外部依存なし
  • JSON / TextHandler が標準提供
  • With(...) でコンテキスト付きロガーを派生できる

出力例(JSON ハンドラ):

{"time":"2026-05-14T10:00:00Z","level":"INFO","msg":"http","request_id":"abc-123","method":"GET","path":"/todos","status":200,"duration_ms":15}

本番ではこれを Datadog / Cloud Logging / Loki に流して request_id で串刺し検索する。

6. ページネーション設計

// internal/todo/handler.go の list の改良
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	limit, _ := strconv.Atoi(q.Get("limit"))
	offset, _ := strconv.Atoi(q.Get("offset"))
 
	// デフォルトと上限
	if limit <= 0 || limit > 100 {
		limit = 20
	}
	if offset < 0 {
		offset = 0
	}
 
	items, total, err := h.svc.List(r.Context(), limit, offset)
	if err != nil {
		httperr.Write(w, r, h.logger, err)
		return
	}
 
	// レスポンスにメタ情報を含める
	resp := map[string]any{
		"items":  items,
		"total":  total,
		"limit":  limit,
		"offset": offset,
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(resp)
}

offset / limit vs cursor の使い分け

方式利点欠点
offset/limit実装が単純、ページ番号でジャンプ可能深いページで遅い(OFFSET 100000 は全件スキャン)
cursor高速、データ追加に強い「3ページ目に飛ぶ」ができない

一般 API(管理画面、社内ツール)は offset/limit、Twitter / Stripe のような大規模 API は cursor。

cursor の例:

GET /todos?after=eyJpZCI6MTAwfQ==&limit=20
→ {"items": [...], "next_cursor": "eyJpZCI6MTIwfQ=="}

after は base64 でエンコードした最後の ID 等。

上限がないと事故る

// NG: 何件でも返せるエンドポイント
SELECT * FROM todos

クライアントが ?limit=999999 を送ると DB の全行をメモリに乗せる → OOM → サーバー停止。

鉄則: 必ず上限を設ける(コード上で if limit > 100 { limit = 100 })。

7. Content-Type と Accept

// リクエスト Content-Type 検証
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	ct := r.Header.Get("Content-Type")
	if !strings.HasPrefix(ct, "application/json") {
		return httperr.BadRequest("Content-Type must be application/json", nil)
	}
 
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	defer r.Body.Close()
 
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		return httperr.BadRequest("invalid json", err)
	}
	return nil
}

Accept ヘッダーへの対応

高度な API は Accept ヘッダーで返す形式を変える(content negotiation):

Accept: application/json    → JSON で返す
Accept: application/xml     → XML で返す(必要なら)
Accept: text/csv            → CSV で返す

JSON だけサポートする API でも、Accept: text/html のような変な値が来た時に 406 Not Acceptable を返す選択肢もある。


セッション③: Graceful shutdown と統合(30分)

8. Graceful shutdown - サーバーを「綺麗に止める」

// cmd/server/main.go
package main
 
import (
	"context"
	"errors"
	"log/slog"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
 
	"github.com/yourname/todo-api/internal/httpmw"
	"github.com/yourname/todo-api/internal/todo"
)
 
func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	slog.SetDefault(logger)
 
	repo := todo.NewMemoryRepo()
	svc := todo.NewService(repo)
	h := todo.NewHandler(svc, logger)
 
	mux := http.NewServeMux()
	h.Routes(mux)
 
	// ミドルウェアチェーン(外側→内側)
	handler := chain(
		mux,
		httpmw.AccessLog,
		httpmw.Recover,
		httpmw.InjectLogger(logger),
		httpmw.RequestID,
	)
 
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       30 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       120 * time.Second,
		MaxHeaderBytes:    1 << 20,
		BaseContext: func(_ net.Listener) context.Context {
			return context.Background()
		},
	}
 
	// シグナル待ち goroutine
	idleConnsClosed := make(chan struct{})
	go func() {
		sigChan := make(chan os.Signal, 1)
		signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
		<-sigChan
		logger.Info("shutdown signal received")
 
		// 30 秒以内に既存リクエストを完了させる
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
 
		if err := srv.Shutdown(ctx); err != nil {
			logger.Error("graceful shutdown failed", "err", err)
		}
		close(idleConnsClosed)
	}()
 
	logger.Info("server starting", "addr", srv.Addr)
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		logger.Error("server failed", "err", err)
		os.Exit(1)
	}
 
	<-idleConnsClosed
	logger.Info("server stopped cleanly")
}
 
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
	// 引数の右側から包む(=外側に重ねる)
	for _, mw := range mws {
		h = mw(h)
	}
	return h
}

Graceful shutdown はなぜ必要か

Kubernetes / Docker などのデプロイで、コンテナを停止する時:

  1. SIGTERM がプロセスに送られる
  2. そのまま os.Exit すると、進行中のリクエストが途中で切断される(クライアントは 502 を見る)
  3. ロードバランサが新規リクエストを送らないようにする猶予が必要

Graceful shutdown:

  • SIGTERM を受け取ったら 新規接続を拒否
  • 既存リクエストは最後まで処理
  • タイムアウト後、強制終了

これでデプロイ時のユーザー影響をゼロに近づける。Kubernetes の terminationGracePeriodSeconds と合わせて設計する。

http.Server.Shutdown の挙動

  • 新規接続の受付を停止
  • 既存接続が完了するまで待つ
  • 指定 context のタイムアウトで打ち切り
  • 完了後 nil、タイムアウトなら context.DeadlineExceeded を返す

注意: WebSocket や長時間ハンドラは「自分で止まる」必要がある。Shutdown は接続待ちを終わらせるだけで、ハンドラ内のループは止まらない。ハンドラは r.Context() を見て自分で抜ける必要がある。

9. 完成形の handler(要点抜粋)

// internal/todo/handler.go
package todo
 
import (
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strconv"
 
	"github.com/yourname/todo-api/internal/httperr"
)
 
type Handler struct {
	svc    *Service
	logger *slog.Logger
}
 
func NewHandler(s *Service, l *slog.Logger) *Handler {
	return &Handler{svc: s, logger: l}
}
 
func (h *Handler) Routes(mux *http.ServeMux) {
	mux.HandleFunc("GET /todos", h.list)
	mux.HandleFunc("POST /todos", h.create)
	mux.HandleFunc("GET /todos/{id}", h.get)
	mux.HandleFunc("PATCH /todos/{id}", h.update)
	mux.HandleFunc("DELETE /todos/{id}", h.delete)
	mux.HandleFunc("GET /healthz", h.health)
}
 
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("ok"))
}
 
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
	var in CreateRequest
	if err := decodeJSON(w, r, &in); err != nil {
		httperr.Write(w, r, h.logger, err)
		return
	}
	if err := in.Validate(); err != nil {
		httperr.Write(w, r, h.logger, err)
		return
	}
 
	t, err := h.svc.Create(r.Context(), in.Title, in.Priority)
	if err != nil {
		httperr.Write(w, r, h.logger, err)
		return
	}
 
	w.Header().Set("Location", "/todos/"+strconv.Itoa(t.ID))
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(t)
}
 
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		httperr.Write(w, r, h.logger, httperr.BadRequest("invalid id", err))
		return
	}
 
	t, err := h.svc.Get(r.Context(), id)
	if errors.Is(err, ErrNotFound) {
		httperr.Write(w, r, h.logger, httperr.NotFound("todo not found"))
		return
	}
	if err != nil {
		httperr.Write(w, r, h.logger, httperr.Internal(err))
		return
	}
 
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(t)
}
 
// ... update, delete も同様のパターン

10. セキュリティチェックリスト(リリース前)

本番リリース前の最終チェック

入力検証

  • 全 POST/PUT/PATCH で MaxBytesReader 設定
  • 全 POST/PUT/PATCH で DisallowUnknownFields 設定
  • バリデータでフィールド長 / 型チェック
  • Content-Type 検証

エラー処理

  • エラーメッセージに内部情報(SQL、ファイルパス、スタック)を含めない
  • 全 panic を recover で捕捉、500 を返す
  • HTTP ステータスコードを意味通りに使う

タイムアウト

  • ReadHeaderTimeout 設定(Slowloris 対策)
  • ReadTimeout / WriteTimeout 設定
  • IdleTimeout 設定

可観測性

  • 全リクエストにリクエスト ID 付与、レスポンスヘッダにも返す
  • 構造化ログ(JSON)で時刻・メソッド・パス・ステータス・所要時間を記録
  • /healthz エンドポイント実装

運用

  • SIGTERM での graceful shutdown 実装
  • ページネーション上限あり(最大100件など)
  • レート制限ミドルウェア(次のレベルで導入)
  • TLS 必須(リバプロで対応 or ListenAndServeTLS
  • 認証ミドルウェア(次のレベルで)

11. 動作確認

go run ./cmd/server/main.go &
SERVER_PID=$!
 
# 作成
curl -i -X POST http://localhost:8080/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"買い物","priority":3}'
# → 201 Created, Location: /todos/1
 
# バリデーションエラー
curl -i -X POST http://localhost:8080/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"","priority":99}'
# → 422 Unprocessable Entity
# → {"title":"Validation Failed","status":422,"errors":{"Title":"min","Priority":"max"}}
 
# 一覧(ページネーション)
curl http://localhost:8080/todos?limit=10
# → {"items":[...], "total":1, "limit":10, "offset":0}
 
# 取得
curl http://localhost:8080/todos/1
# → 200 + JSON
 
# 存在しない
curl -i http://localhost:8080/todos/999
# → 404 Not Found
 
# 削除
curl -i -X DELETE http://localhost:8080/todos/1
# → 204 No Content
 
# graceful shutdown 確認(別ターミナルで遅いハンドラを叩きつつ SIGTERM)
kill -TERM $SERVER_PID
# → ログ: "shutdown signal received" → "server stopped cleanly"

アンチパターン: 認証なしの「内部 API」

// 「内部だから」「テスト中だから」と認証を省略した API
r.Use(/* なし */)
r.Post("/admin/delete-all", deleteAllUsers)

なぜNG: 「内部」が「外部」になる瞬間は突然来る(VPN 障害、Pod がパブリックに露出、開発者ミス)。全エンドポイントで認証 + 認可 を前提とすべき。

修正: 最低限の Bearer Token、本格的には OIDC / mTLS / API Key + IP 制限を Layer する。


練習課題

  1. 上記コードを完成させ、curl で全 CRUD を動作確認
  2. PATCH /todos/{id} でポインタフィールドによる部分更新を実装
  3. cursor ベースのページネーションを実装(base64 でカーソルをエンコード)
  4. レート制限ミドルウェア(golang.org/x/time/rate)を追加して、1分あたり60リクエストに制限
  5. Bearer Token 検証ミドルウェアを追加し、/healthz 以外を保護
  6. graceful shutdown のタイムアウトを5秒にし、/slow(10秒スリープ)を叩きながら kill して挙動を観察
  7. ボーナス: chi に書き換え、サブルーター + Use の組合せでミドルウェア構造を整理

締め: git で証跡を残す

cd ~/learn/go/level2/day07
git add .
git commit -m "feat(go-rest): 実務OKレベルのTODO REST API (バリデーション/構造化ログ/graceful shutdown)"
exit

アンチパターン集 - やらかし事例

REST API 統合フェーズのやらかし

1. エラーでも常に 200 を返す

w.WriteHeader(200)
json.Encode(map[string]any{"success": false, "error": "..."})

HTTP の意味論を壊す。CDN/LB/監視ツールが全て正常扱い。メトリクス取れず障害見えない

2. ページネーション上限なし ?limit=999999 で DB を OOM 直行便。if limit > 100 { limit = 100 } は必須。

3. graceful shutdown 無しのデプロイ Kubernetes が SIGTERM 送って即 kill。進行中リクエストが 502 になる

4. リクエスト ID 振らない 障害時に「このユーザーの2025-01-15 14:23 のリクエストのログ追って」が 不可能

5. SQL/パス情報をエラーレスポンスに含める 攻撃者に内部構造を見せる典型的脆弱性。ログには詳細、レスポンスには汎用

6. 422 と 400 を区別しない JSON 壊れている(400)と バリデーション失敗(422)を全部 400 で返す → クライアントが対処方針を立てられない。

対比表で違いを明確化

HTTP ステータスコード使い分け早見表

場面コード
GET 成功200
POST 作成成功201 + Location ヘッダ
DELETE 成功(ボディなし)204
JSON 構文壊れ400
未認証401
認証OKだが権限なし403
リソースなし404
重複登録・楽観ロック失敗409
バリデーション失敗422
レート制限429
サーバー側バグ500
上流障害502/503/504

ページネーション方式の選び分け

方式利点欠点用途
offset/limit単純、ページ番号でジャンプ深いページ遅い管理画面
cursor高速、追加に強い任意ページに飛べないTwitter/Stripe

自己評価チェックリスト

手を動かせた

  • validator でバリデーション組んだ
  • 422 で フィールド別エラーを返した
  • AppError で HTTP ステータスと内部詳細を分離
  • slog でリクエスト ID 付き構造化ログ
  • ページネーション + 上限を実装
  • SIGTERM での graceful shutdown を実装
  • curl で全 CRUD + エラーケースを動作確認

説明できる

  • 「実務OK」の6基準を列挙
  • RFC 7807 の Problem+JSON 構造
  • 400 vs 422 の使い分け
  • srv.Shutdown(ctx) が何を待ち何を待たないか
  • 「本番投入前チェックリスト」を脳内で読み上げる

やらかし回避

  • エラーでも 200 で返さない
  • ページネーション上限を必ず設ける
  • graceful shutdown 無しでデプロイしない
  • エラーレスポンスに内部情報を含めない
  • リクエスト ID を必ず振る

詰まった時のチートシート

やりたいことコード
バリデーションvalidator.New().Struct(req)
422 エラーvalidator.ValidationErrors を検知して errors フィールドで返す
エラー統一httperr.AppError + httperr.Write
graceful shutdownsignal.Notifysrv.Shutdown(ctx)
リクエスト IDuuid + ミドルウェアで context に積む
構造化ログslog.New(slog.NewJSONHandler(os.Stdout, nil))
ページ上限if limit > 100 { limit = 100 }
ロケーションヘッダw.Header().Set("Location", "/todos/"+id)

「実務OK」基準

  • TODO API のような CRUD を、エラーハンドリング・バリデーション・ログ・shutdown 込みで2-3時間で書ける
  • 「本番投入前チェックリスト」を脳内で読み上げられる
  • go-playground/validator のタグでフィールド検証を組める
  • http.Server.Shutdown が「何を待っていて、何を待っていないか」を語れる
  • 422 / 400 / 404 / 409 を適切に使い分けられる

さらに深掘りするなら


Level 2 まとめ

ここまでで:

  • 2-1 標準 net/http の設計思想と本番タイムアウト
  • 2-2 Go 1.22 ServeMux と chi のルーティング
  • 2-3 JSON の罠と安全な復号
  • 2-4 ミドルウェアの合成と context 連携
  • 2-5 エラー設計(値 / ラップ / panic 境界)
  • 2-6 パッケージ分割とレイヤード設計
  • 2-7 統合 - 実務OKレベルの REST API

をやりきった。

次の Level 3 では データベース(database/sql、コネクションプール、トランザクション)並行処理(goroutine / channel / sync)テスト戦略Observability(OpenTelemetry) に進む。Level 2 で組んだ API に魂を入れる作業。

つながりの予告