diff --git a/docs/plans/2026-04-16-supply-api-runtime-http-adapter-plan.md b/docs/plans/2026-04-16-supply-api-runtime-http-adapter-plan.md new file mode 100644 index 00000000..93d5952f --- /dev/null +++ b/docs/plans/2026-04-16-supply-api-runtime-http-adapter-plan.md @@ -0,0 +1,134 @@ +# Supply API Runtime HTTP Adapter Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把 `Runtime.BuildServer` 里剩余的 `BuildServerOptions` 构造收成更明确的 adapter,让 runtime 到 HTTP 启动的边界完全声明式。 + +**Architecture:** 保留 `Runtime.BuildServer` 和 `BuildServer` 的现有外部接口,新增内部 helper 负责从 runtime 提取健康检查和构造 `BuildServerOptions`。`Runtime.BuildServer` 最终只做 `adapt runtime -> BuildServer(opts)`,把边界映射与 HTTP 启动解耦。 + +**Tech Stack:** Go, Go test + +--- + +### Task 1: 提取 runtime health check adapter + +**Files:** +- Modify: `supply-api/internal/app/runtime.go` +- Modify: `supply-api/internal/app/runtime_test.go` + +**Step 1: Write the failing test** + +```go +func TestResolveRuntimeHealthChecks_OmitsUnavailableDependencies(t *testing.T) { + checks := resolveRuntimeHealthChecks(&Runtime{}) + if checks.DBHealthCheck != nil || checks.RedisHealthCheck != nil { + t.Fatal("expected nil health checks without dependencies") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestResolveRuntimeHealthChecks_(OmitsUnavailableDependencies|ExposesAvailableDependencies)' -v` +Expected: FAIL,因为 helper 尚不存在 + +**Step 3: Write minimal implementation** + +```go +type runtimeHealthChecks struct { ... } +func resolveRuntimeHealthChecks(runtime *Runtime) runtimeHealthChecks { ... } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestResolveRuntimeHealthChecks_(OmitsUnavailableDependencies|ExposesAvailableDependencies)' -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add supply-api/internal/app/runtime.go supply-api/internal/app/runtime_test.go +git commit -m "refactor(supply-api): extract runtime health check adapter" +``` + +### Task 2: 提取 BuildServerOptions adapter + +**Files:** +- Modify: `supply-api/internal/app/runtime.go` +- Modify: `supply-api/internal/app/runtime_test.go` + +**Step 1: Write the failing test** + +```go +func TestAdaptRuntimeToBuildServerOptions_MapsRuntimeFields(t *testing.T) { + opts, err := adaptRuntimeToBuildServerOptions(runtime) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.SupplyAPI != runtime.supplyAPI { + t.Fatal("expected supply api to be preserved") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestAdaptRuntimeToBuildServerOptions_(RequiresRuntime|MapsRuntimeFields)' -v` +Expected: FAIL,因为 helper 尚不存在 + +**Step 3: Write minimal implementation** + +```go +func adaptRuntimeToBuildServerOptions(runtime *Runtime) (BuildServerOptions, error) { ... } +``` + +**Step 4: Run test to verify it passes** + +Run: `cd "supply-api" && go test ./internal/app -run 'TestAdaptRuntimeToBuildServerOptions_(RequiresRuntime|MapsRuntimeFields)' -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add supply-api/internal/app/runtime.go supply-api/internal/app/runtime_test.go +git commit -m "refactor(supply-api): extract runtime server options adapter" +``` + +### Task 3: 回归验证与收尾 + +**Files:** +- Modify: `supply-api/internal/app/runtime.go` +- Verify: `supply-api/internal/app/runtime_test.go` +- Verify: `supply-api/internal/app/bootstrap.go` + +**Step 1: Run focused tests** + +Run: `cd "supply-api" && go test ./internal/app -run 'Test(ResolveRuntimeHealthChecks_(OmitsUnavailableDependencies|ExposesAvailableDependencies)|AdaptRuntimeToBuildServerOptions_(RequiresRuntime|MapsRuntimeFields)|BuildServer_.*|BuildRuntime_.*)' -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-runtime-http-adapter-plan.md supply-api/internal/app/runtime.go supply-api/internal/app/runtime_test.go +git commit -m "refactor(supply-api): declarify runtime http adapter" +``` diff --git a/supply-api/internal/app/runtime.go b/supply-api/internal/app/runtime.go index de378077..ddf5491d 100644 --- a/supply-api/internal/app/runtime.go +++ b/supply-api/internal/app/runtime.go @@ -87,6 +87,11 @@ type runtimeAPIBundle struct { rateLimitConfig *middleware.RateLimitConfig } +type runtimeHealthChecks struct { + DBHealthCheck func(context.Context) error + RedisHealthCheck func(context.Context) error +} + // BuildRuntime 构建 supply-api 运行时依赖。 func BuildRuntime(opts RuntimeOptions) (*Runtime, error) { return buildRuntimeWithFactory(opts, runtimeFactory{ @@ -333,30 +338,44 @@ func defaultRuntimeTuning() runtimeTuning { // BuildServer 使用运行时依赖构建 HTTP server。 func (r *Runtime) BuildServer() (*http.Server, error) { - if r == nil { - return nil, errors.New("runtime is required") + opts, err := adaptRuntimeToBuildServerOptions(r) + if err != nil { + return nil, err + } + return BuildServer(opts) +} + +func resolveRuntimeHealthChecks(runtime *Runtime) runtimeHealthChecks { + var checks runtimeHealthChecks + if runtime == nil { + return checks + } + if runtime.db != nil { + checks.DBHealthCheck = runtime.db.HealthCheck + } + if runtime.redisCache != nil { + checks.RedisHealthCheck = runtime.redisCache.HealthCheck + } + return checks +} + +func adaptRuntimeToBuildServerOptions(runtime *Runtime) (BuildServerOptions, error) { + if runtime == nil { + return BuildServerOptions{}, errors.New("runtime is required") } - var dbHealthCheck func(context.Context) error - var redisHealthCheck func(context.Context) error - if r.db != nil { - dbHealthCheck = r.db.HealthCheck - } - if r.redisCache != nil { - redisHealthCheck = r.redisCache.HealthCheck - } - - return BuildServer(BuildServerOptions{ - Env: r.env, - ServerConfig: r.serverConfig, - Logger: r.logger, - SupplyAPI: r.supplyAPI, - AlertAPI: r.alertAPI, - AuthMiddleware: r.authMiddleware, - RateLimitConfig: r.rateLimitConfig, - DBHealthCheck: dbHealthCheck, - RedisHealthCheck: redisHealthCheck, - }) + healthChecks := resolveRuntimeHealthChecks(runtime) + return BuildServerOptions{ + Env: runtime.env, + ServerConfig: runtime.serverConfig, + Logger: runtime.logger, + SupplyAPI: runtime.supplyAPI, + AlertAPI: runtime.alertAPI, + AuthMiddleware: runtime.authMiddleware, + RateLimitConfig: runtime.rateLimitConfig, + DBHealthCheck: healthChecks.DBHealthCheck, + RedisHealthCheck: healthChecks.RedisHealthCheck, + }, nil } // Close 关闭运行时持有的外部资源。 diff --git a/supply-api/internal/app/runtime_test.go b/supply-api/internal/app/runtime_test.go index a579ec0e..aa21ad67 100644 --- a/supply-api/internal/app/runtime_test.go +++ b/supply-api/internal/app/runtime_test.go @@ -13,6 +13,7 @@ import ( "lijiaoqiao/supply-api/internal/config" "lijiaoqiao/supply-api/internal/domain" "lijiaoqiao/supply-api/internal/messaging" + "lijiaoqiao/supply-api/internal/middleware" "lijiaoqiao/supply-api/internal/repository" ) @@ -354,6 +355,84 @@ func TestBuildRuntime_DevFallbackLogsWarnings(t *testing.T) { } } +func TestResolveRuntimeHealthChecks_OmitsUnavailableDependencies(t *testing.T) { + checks := resolveRuntimeHealthChecks(&Runtime{}) + if checks.DBHealthCheck != nil { + t.Fatal("expected nil db health check without database") + } + if checks.RedisHealthCheck != nil { + t.Fatal("expected nil redis health check without redis") + } +} + +func TestResolveRuntimeHealthChecks_ExposesAvailableDependencies(t *testing.T) { + checks := resolveRuntimeHealthChecks(&Runtime{ + db: &repository.DB{}, + redisCache: &cache.RedisCache{}, + }) + if checks.DBHealthCheck == nil { + t.Fatal("expected db health check") + } + if checks.RedisHealthCheck == nil { + t.Fatal("expected redis health check") + } +} + +func TestAdaptRuntimeToBuildServerOptions_RequiresRuntime(t *testing.T) { + _, err := adaptRuntimeToBuildServerOptions(nil) + if err == nil { + t.Fatal("expected nil runtime to fail") + } + if !strings.Contains(err.Error(), "runtime is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAdaptRuntimeToBuildServerOptions_MapsRuntimeFields(t *testing.T) { + supplyAPI, alertAPI := mustBuildTestAPIs(t) + authMiddleware := &middleware.AuthMiddleware{} + rateLimitConfig := &middleware.RateLimitConfig{Enabled: true} + + opts, err := adaptRuntimeToBuildServerOptions(&Runtime{ + env: "staging", + logger: testLogger{}, + serverConfig: config.ServerConfig{Addr: ":19090"}, + supplyAPI: supplyAPI, + alertAPI: alertAPI, + authMiddleware: authMiddleware, + rateLimitConfig: rateLimitConfig, + db: &repository.DB{}, + redisCache: &cache.RedisCache{}, + }) + if err != nil { + t.Fatalf("expected adapter to succeed, got %v", err) + } + if opts.Env != "staging" { + t.Fatalf("unexpected env: %s", opts.Env) + } + if opts.ServerConfig.Addr != ":19090" { + t.Fatalf("unexpected server addr: %s", opts.ServerConfig.Addr) + } + if opts.SupplyAPI != supplyAPI { + t.Fatal("expected supply api to be preserved") + } + if opts.AlertAPI != alertAPI { + t.Fatal("expected alert api to be preserved") + } + if opts.AuthMiddleware != authMiddleware { + t.Fatal("expected auth middleware to be preserved") + } + if opts.RateLimitConfig != rateLimitConfig { + t.Fatal("expected rate limit config to be preserved") + } + if opts.DBHealthCheck == nil { + t.Fatal("expected db health check") + } + if opts.RedisHealthCheck == nil { + t.Fatal("expected redis health check") + } +} + func TestRuntime_StartBackgroundWorkers_WithoutDatabaseIsNoop(t *testing.T) { var outboxRepoCalled bool