diff --git a/docs/plans/2026-04-16-supply-api-bootstrap-http-chain-plan.md b/docs/plans/2026-04-16-supply-api-bootstrap-http-chain-plan.md new file mode 100644 index 00000000..92eb32cb --- /dev/null +++ b/docs/plans/2026-04-16-supply-api-bootstrap-http-chain-plan.md @@ -0,0 +1,129 @@ +# Supply API Bootstrap HTTP Chain Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把 `supply-api/internal/app/bootstrap.go` 中的 HTTP 装配链拆清楚,让 `BuildServer` 只负责校验和组装,路由注册与中间件编排各自收口到独立 helper。 + +**Architecture:** 保留现有 `BuildServer` 外部接口与运行时语义,新增路由装配 helper 负责 health/supply/alert 路由注册,新增中间件链 helper 负责通用中间件和 prod/staging 鉴权链编排。先写 helper 级失败测试锁住对外可观测行为,再做最小重构并跑整组回归。 + +**Tech Stack:** Go, Go test + +--- + +### Task 1: 提取路由注册 helper + +**Files:** +- Modify: `supply-api/internal/app/bootstrap.go` +- Modify: `supply-api/internal/app/bootstrap_test.go` + +**Step 1: Write the failing test** + +```go +func TestBuildRouteMux_RegistersHealthAndSupplyRoutes(t *testing.T) { + mux := buildRouteMux(buildRouteMuxOptions{...}) + // /actuator/health => 200 + // /api/v1/supply/accounts/verify => 405 on GET, proving route exists +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildRouteMux_RegistersHealthAndSupplyRoutes' -v` +Expected: FAIL,因为 helper 尚不存在 + +**Step 3: Write minimal implementation** + +```go +type buildRouteMuxOptions struct { ... } +func buildRouteMux(opts buildRouteMuxOptions) *http.ServeMux { ... } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildRouteMux_RegistersHealthAndSupplyRoutes' -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add supply-api/internal/app/bootstrap.go supply-api/internal/app/bootstrap_test.go +git commit -m "refactor(supply-api): extract bootstrap route mux builder" +``` + +### Task 2: 提取中间件链 helper + +**Files:** +- Modify: `supply-api/internal/app/bootstrap.go` +- Modify: `supply-api/internal/app/bootstrap_test.go` + +**Step 1: Write the failing test** + +```go +func TestBuildMiddlewareChain_ProdRejectsQueryKey(t *testing.T) { + handler := buildMiddlewareChain(..., http.HandlerFunc(...)) + // /api/v1/supply/accounts?token=bad => 401 +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildMiddlewareChain_ProdRejectsQueryKey' -v` +Expected: FAIL,因为 helper 尚不存在 + +**Step 3: Write minimal implementation** + +```go +type middlewareChainOptions struct { ... } +func buildMiddlewareChain(opts middlewareChainOptions, next http.Handler) http.Handler { ... } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestBuildMiddlewareChain_ProdRejectsQueryKey' -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add supply-api/internal/app/bootstrap.go supply-api/internal/app/bootstrap_test.go +git commit -m "refactor(supply-api): extract bootstrap middleware chain" +``` + +### Task 3: 回归验证与收尾 + +**Files:** +- Modify: `supply-api/internal/app/bootstrap.go` +- Verify: `supply-api/internal/app/bootstrap_test.go` +- Verify: `supply-api/internal/app/runtime.go` + +**Step 1: Run focused tests** + +Run: `cd "supply-api" && go test ./internal/app -run 'Test(BuildRouteMux_RegistersHealthAndSupplyRoutes|BuildMiddlewareChain_ProdRejectsQueryKey|BuildServer_.*)' -v` +Expected: PASS + +**Step 2: Run package regression** + +Run: `cd "supply-api" && go test ./internal/app ./cmd/supply-api ./internal/httpapi` +Expected: PASS + +**Step 3: Run e2e tests** + +Run: `cd "supply-api" && go test -tags=e2e ./e2e` +Expected: PASS + +**Step 4: Run repo exit verification** + +Run: `bash "scripts/ci/repo_integrity_check.sh"` +Expected: PASS + +**Step 5: Check formatting** + +Run: `git diff --check` +Expected: no output + +**Step 6: Commit** + +```bash +git add docs/plans/2026-04-16-supply-api-bootstrap-http-chain-plan.md supply-api/internal/app/bootstrap.go supply-api/internal/app/bootstrap_test.go +git commit -m "refactor(supply-api): split bootstrap http assembly" +``` diff --git a/supply-api/internal/app/bootstrap.go b/supply-api/internal/app/bootstrap.go index 7d9fb6da..baa1742b 100644 --- a/supply-api/internal/app/bootstrap.go +++ b/supply-api/internal/app/bootstrap.go @@ -26,6 +26,20 @@ type BuildServerOptions struct { RedisHealthCheck func(context.Context) error } +type buildRouteMuxOptions struct { + SupplyAPI *httpapi.SupplyAPI + AlertAPI *httpapi.AlertAPI + DBHealthCheck func(context.Context) error + RedisHealthCheck func(context.Context) error +} + +type middlewareChainOptions struct { + Env string + Logger logging.Logger + AuthMiddleware *middleware.AuthMiddleware + RateLimitConfig *middleware.RateLimitConfig +} + // BuildServer 构建可复用的 HTTP server 与 handler 装配。 func BuildServer(opts BuildServerOptions) (*http.Server, error) { if opts.SupplyAPI == nil { @@ -52,13 +66,18 @@ func BuildServer(opts BuildServerOptions) (*http.Server, error) { rateLimitConfig.Enabled = env != "dev" } - mux := http.NewServeMux() - healthHandler := httpapi.NewHealthHandlerWithDefaults(opts.DBHealthCheck, opts.RedisHealthCheck) - healthHandler.RegisterRoutes(mux) - opts.SupplyAPI.Register(mux) - opts.AlertAPI.Register(mux) - - handler := buildHandler(env, mux, opts.Logger, opts.AuthMiddleware, rateLimitConfig) + mux := buildRouteMux(buildRouteMuxOptions{ + SupplyAPI: opts.SupplyAPI, + AlertAPI: opts.AlertAPI, + DBHealthCheck: opts.DBHealthCheck, + RedisHealthCheck: opts.RedisHealthCheck, + }) + handler := buildMiddlewareChain(middlewareChainOptions{ + Env: env, + Logger: opts.Logger, + AuthMiddleware: opts.AuthMiddleware, + RateLimitConfig: rateLimitConfig, + }, mux) serverConfig := normalizeServerConfig(opts.ServerConfig) @@ -91,24 +110,27 @@ func normalizeServerConfig(serverConfig config.ServerConfig) config.ServerConfig return serverConfig } -func buildHandler( - env string, - mux *http.ServeMux, - logger logging.Logger, - authMiddleware *middleware.AuthMiddleware, - rateLimitConfig *middleware.RateLimitConfig, -) http.Handler { - var handler http.Handler = mux +func buildRouteMux(opts buildRouteMuxOptions) *http.ServeMux { + mux := http.NewServeMux() + healthHandler := httpapi.NewHealthHandlerWithDefaults(opts.DBHealthCheck, opts.RedisHealthCheck) + healthHandler.RegisterRoutes(mux) + opts.SupplyAPI.Register(mux) + opts.AlertAPI.Register(mux) + return mux +} + +func buildMiddlewareChain(opts middlewareChainOptions, next http.Handler) http.Handler { + var handler http.Handler = next handler = middleware.RequestID(handler) handler = middleware.Recovery(handler) - handler = middleware.Logging(handler, logger) + handler = middleware.Logging(handler, opts.Logger) handler = middleware.TracingMiddleware(handler) - if env != "dev" { - handler = middleware.NewRateLimitHandler(rateLimitConfig, handler) - handler = authMiddleware.TokenVerifyMiddleware(handler) - handler = authMiddleware.BearerExtractMiddleware(handler) - handler = authMiddleware.QueryKeyRejectMiddleware(handler) + if opts.Env != "dev" { + handler = middleware.NewRateLimitHandler(opts.RateLimitConfig, handler) + handler = opts.AuthMiddleware.TokenVerifyMiddleware(handler) + handler = opts.AuthMiddleware.BearerExtractMiddleware(handler) + handler = opts.AuthMiddleware.QueryKeyRejectMiddleware(handler) } return handler diff --git a/supply-api/internal/app/bootstrap_test.go b/supply-api/internal/app/bootstrap_test.go index 8d50b93b..2c36add7 100644 --- a/supply-api/internal/app/bootstrap_test.go +++ b/supply-api/internal/app/bootstrap_test.go @@ -144,6 +144,29 @@ func TestBuildServer_DefaultsTimeoutsWhenUnset(t *testing.T) { } } +func TestBuildRouteMux_RegistersHealthAndSupplyRoutes(t *testing.T) { + supplyAPI, alertAPI := mustBuildTestAPIs(t) + + mux := buildRouteMux(buildRouteMuxOptions{ + SupplyAPI: supplyAPI, + AlertAPI: alertAPI, + }) + + healthReq := httptest.NewRequest(http.MethodGet, "/actuator/health", nil) + healthRec := httptest.NewRecorder() + mux.ServeHTTP(healthRec, healthReq) + if healthRec.Code != http.StatusOK { + t.Fatalf("unexpected health status: got=%d want=%d", healthRec.Code, http.StatusOK) + } + + supplyReq := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts/verify", nil) + supplyRec := httptest.NewRecorder() + mux.ServeHTTP(supplyRec, supplyReq) + if supplyRec.Code != http.StatusMethodNotAllowed { + t.Fatalf("unexpected supply status: got=%d want=%d", supplyRec.Code, http.StatusMethodNotAllowed) + } +} + func mustBuildTestAPIs(t *testing.T) (*httpapi.SupplyAPI, *httpapi.AlertAPI) { t.Helper() @@ -205,3 +228,39 @@ func TestBuildServer_ProdBuildsAuthenticatedHandler(t *testing.T) { t.Fatal("expected non-nil server and handler") } } + +func TestBuildMiddlewareChain_ProdRejectsQueryKey(t *testing.T) { + authMiddleware := middleware.NewAuthMiddleware( + middleware.AuthConfig{ + SecretKey: "bootstrap-test-secret-key", + Algorithm: "HS256", + Issuer: "bootstrap-test", + Enabled: true, + }, + middleware.NewTokenCache(), + nil, + nil, + ) + + handler := buildMiddlewareChain(middlewareChainOptions{ + Env: "prod", + Logger: testLogger{}, + AuthMiddleware: authMiddleware, + RateLimitConfig: &middleware.RateLimitConfig{ + Enabled: false, + }, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/supply/accounts?token=bad", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("unexpected status: got=%d want=%d", rec.Code, http.StatusUnauthorized) + } + if !strings.Contains(rec.Body.String(), "QUERY_KEY_NOT_ALLOWED") { + t.Fatalf("unexpected body: %s", rec.Body.String()) + } +}