mcpField
G
go-api-design

go-api-design

github.com/eduardo-sl開発83187 回インストール2 週間前に更新
リポジトリ

go-api-design について

サービスの契約を保守する開発者向けに、Go の HTTP API とハンドラー設計を扱うスキルです。標準ライブラリのルーティング、検証、エラー形式、ページネーション、ミドルウェア、正常終了を解説します。

主な機能

  1. 01HTTP ハンドラーと ServeMux の規約
  2. 02ミドルウェア順序とリクエスト検証
  3. 03URL、命名、ページネーションの指針
  4. 04一貫したエラーレスポンス形式
  5. 05正常終了とヘルスチェック

活用シーン

  1. 01Go HTTP サービスのルート設計
  2. 02リクエスト検証とエラーレスポンスのレビュー
  3. 03API のページネーションと終了動作の計画

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/eduardo-sl/go-agent-skills --skill go-api-design

インストール後、Claude Code で/go-api-designを入力して読み込まれたか確認します。

同梱ファイルreferences/handlers-and-middleware.md

詳細

Go API 設計

API は契約です。一度公開すれば、それは約束になります。10 年間保守するつもりで設計してください。実際にそうなる可能性が高いからです。

コミュニティのデフォルトです。go-api-design を明示的に上書きするプロジェクトのスキルまたはエージェント設定ルールがある場合は、そちらが優先されます。

詳細なリファレンス資料は、必要に応じて読み込みます。

  • references/handlers-and-middleware.md — ハンドラー構造体、JSON レスポンスヘルパー、リクエスト ID およびリカバラーミドルウェア、デコードと検証。

以下の概要だけでは不十分な場合に限り、リファレンスファイルを読んでください。

1. HTTP ハンドラーの構造

  • 標準の http.ServeMux(Go 1.22 以降)でルーティングします。"GET /users/{id}" パターンはメソッドに一致し、r.PathValue("id") でワイルドカードを読み取れます。メソッドが異なる場合は Allow とともに 405 が返されます。ルーター依存関係を導入する価値があるのは、ミドルウェアグループやサブルーターのためであり、パスパラメーターのためではありません。
  • ハンドラーは依存関係(ストア、ロガー)を保持する構造体のメソッドにします。パッケージグローバルにはしません。
  • ハンドラーメソッドは何も返しません。レスポンスを書き込み、エラーを自身で処理し、ドメインエラー(errors.Is(err, ErrNotFound))をステータスコードにマッピングして、予期しないエラーのみをログに記録します。
  • Content-Type を設定し、ステータスを一度だけ書き込み、エンコードの失敗をログに記録する、単一の JSON ヘルパーを経由してルーティングします。

2. ミドルウェアのパターン

ミドルウェアは func(http.Handler) http.Handler です。順序は外側から内側へ次のとおりです。

Recoverer → RequestID → Logger → Auth → RateLimit → Handler

Recover は必ず最外側に配置します。認証はビジネスロジックより前に行います。ロガーは処理時間を記録します。

3. リクエストの検証

  • デコード前にボディのサイズを制限します。r.Body = http.MaxBytesReader(w, r.Body, 1<<20)。
  • 型付きリクエスト構造体にデコードし、同じステップで検証します。デコードに成功しても、値が有効とは限りません。
  • 安定したメッセージとともに 400 を返します。デコーダーのエラーテキストをそのまま返してはいけません。

4. URL と命名規則

GET    /api/v1/users          → list users
POST   /api/v1/users          → create user
GET    /api/v1/users/{id}     → get user
PUT    /api/v1/users/{id}     → replace user
PATCH  /api/v1/users/{id}     → partial update
DELETE /api/v1/users/{id}     → delete user

GET    /api/v1/users/{id}/orders → list user orders (nested resource)

ルール:

  • リソースには複数形の名詞を使います。/user ではなく /users。
  • 複数語のパスにはケバブケースを使います。/order-items。
  • API 内で使う JSON フィールドの規則は一つに統一し、混在させません。ここでは camelCase("createdAt")がデフォルトです。既存の API がある場合は、それに合わせてください。
  • URL パスにバージョンを含めます。/api/v1/...。
  • URL に動詞を含めません。/searchUsers ではなく /users/search?q=alice。

5. ページネーション

type PageRequest struct {
    Cursor string `json:"cursor"`
    Limit  int    `json:"limit"`
}

type PageResponse[T any] struct {
    Items      []T    `json:"items"`
    NextCursor string `json:"nextCursor,omitempty"`
    HasMore    bool   `json:"hasMore"`
}

大規模なデータセットでは、offset/limit よりカーソルベースのページネーションを優先します。offset ページネーションは、並行書き込みがあると破綻します。

6. グレースフルシャットダウン

func main() {
    if err := run(context.Background()); err != nil {
        slog.Error("server stopped", slog.Any("error", err))
        os.Exit(1)
    }
}

func run(ctx context.Context) error {
    ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{
        Addr:              ":8080",
        Handler:           router,
        ReadHeaderTimeout: 5 * time.Second, // Slowloris guard
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      15 * time.Second,
        IdleTimeout:       120 * time.Second,
    }

    errc := make(chan error, 1)
    go func() { errc <- srv.ListenAndServe() }()

    select {
    case err := <-errc:
        return fmt.Errorf("listen: %w", err) // e.g. port already in use
    case <-ctx.Done():
    }

    // ctx is already cancelled; shutdown needs its own deadline.
    shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        return fmt.Errorf("shutdown: %w", err)
    }
    if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
        return err
    }
    return nil
}
  • ReadHeaderTimeout は Slowloris 攻撃への防御策です。これがないと、クライアントはヘッダーバイトを少しずつ送信し続けて接続を開いたままにできます。これが設定されていないサーバーには、gosec が G112 の警告を出します。
  • http.ErrServerClosed との比較には == ではなく、必ず errors.Is を使います。
  • 終了するのは main だけにします。サーバー処理の goroutine 内で log.Fatal を呼ぶと、シャットダウン処理を含むすべての defer によるクリーンアップが実行されません。

7. ヘルスチェックエンドポイント

// Liveness: is the process alive? GET /healthz → 200, no dependency checks
// Readiness: should it receive traffic? GET /readyz → 200 or 503
func (h *HealthHandler) handleReady(w http.ResponseWriter, r *http.Request) {
    if h.draining.Load() { // atomic.Bool, set when shutdown starts
        h.respondError(w, http.StatusServiceUnavailable, "shutting down")
        return
    }
    if err := h.db.PingContext(r.Context()); err != nil {
        h.respondError(w, http.StatusServiceUnavailable, "database unavailable")
        return
    }
    h.respondJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

ロードバランサーの背後で SIGTERM を受けたら、draining を設定し、ロードバランサーがルーティングを停止するまで readiness の期間分だけ待ってから、srv.Shutdown を呼び出します。先にシャットダウンすると、ロードバランサーがまだ送信中のリクエストを拒否してしまいます。liveness には依存関係のチェックを含めません。データベースの障害によってオーケストレーターがすべてのレプリカを再起動する事態を避けるためです。

8. エラーレスポンス形式

API 全体で一貫したエラーレスポンスを使用します。

{
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "invalid request parameters",
        "details": [
            {"field": "email", "message": "must be a valid email"}
        ]
    }
}

ハンドラーの境界で内部エラーを HTTP ステータスコードにマッピングします。内部エラーをクライアントに決して漏らしてはいけません。標準的な形式には、RFC 9457 の problem details(application/problem+json)を使います。型は go-openapi スキルにあります。

9. 冪等性とブラウザークライアント

  • 作成や課金を行う POST エンドポイントでは、Idempotency-Key ヘッダーを受け付けます。キーからレスポンスへの対応を一定期間保存し、同じキーが再度使われた場合は保存済みのレスポンスを再生し、同時に発生した重複リクエストは 409 で拒否します。クライアントやプロキシは再試行します。これがないと、再試行によって二重課金が発生します。
  • Cookie を使ってブラウザーから呼び出される API には CSRF 保護が必要です。http.CrossOriginProtection(Go 1.25 以降)を使用します。詳細は go-security-audit スキルが担当します。

コメント