refactor(supply-api): slim runtime constructor prelude

This commit is contained in:
Your Name
2026-04-16 15:38:29 +08:00
parent 8eab2a10f7
commit 6f35b3e1ad
2 changed files with 261 additions and 49 deletions

View File

@@ -81,6 +81,16 @@ type runtimeFactory struct {
newRedisCache func(cfg config.RedisConfig) (*cache.RedisCache, error)
}
type runtimeBuildInputs struct {
env string
cfg *config.Config
logger logging.Logger
initCtx context.Context
now func() time.Time
isProd bool
tuning runtimeTuning
}
type runtimeStoreBundle struct {
accountStore domain.AccountStore
packageStore domain.PackageStore
@@ -129,23 +139,42 @@ func BuildRuntime(opts RuntimeOptions) (*Runtime, error) {
}
func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runtime, error) {
if opts.Config == nil {
return nil, errors.New("config is required")
}
if opts.Logger == nil {
return nil, errors.New("logger is required")
inputs, err := resolveRuntimeBuildInputs(opts)
if err != nil {
return nil, err
}
if factory.newDB == nil {
factory.newDB = repository.NewDB
resources, err := initializeRuntimeExternalResources(inputs, factory)
if err != nil {
return nil, err
}
if factory.newRedisCache == nil {
factory.newRedisCache = cache.NewRedisCache
storeBundle := buildStoreBundle(resources.db, inputs.logger)
securityBundle := buildSecurityBundle(inputs.env, inputs.cfg, inputs.logger, storeBundle.auditStore, resources.redisCache, storeBundle.tokenStatusRepo)
apiBundle, err := buildAPIBundle(inputs.env, inputs.cfg, inputs.now, inputs.tuning, inputs.logger, inputs.isProd, storeBundle)
if err != nil {
return nil, err
}
startupViews := buildRuntimeStartupViews(inputs.env, inputs.logger, inputs.cfg.Server, inputs.tuning, securityBundle, apiBundle)
return &Runtime{
resources: resources,
startupViews: startupViews,
}, nil
}
func resolveRuntimeBuildInputs(opts RuntimeOptions) (runtimeBuildInputs, error) {
if opts.Config == nil {
return runtimeBuildInputs{}, errors.New("config is required")
}
if opts.Logger == nil {
return runtimeBuildInputs{}, errors.New("logger is required")
}
env, err := ResolveEnv(opts.Env)
if err != nil {
return nil, err
return runtimeBuildInputs{}, err
}
now := opts.Now
if now == nil {
@@ -156,62 +185,88 @@ func buildRuntimeWithFactory(opts RuntimeOptions, factory runtimeFactory) (*Runt
initCtx = context.Background()
}
isProd := env == "prod"
tuning := defaultRuntimeTuning()
return runtimeBuildInputs{
env: env,
cfg: opts.Config,
logger: opts.Logger,
initCtx: initCtx,
now: now,
isProd: env == "prod",
tuning: defaultRuntimeTuning(),
}, nil
}
db, err := factory.newDB(initCtx, opts.Config.Database)
func withDefaultRuntimeFactory(factory runtimeFactory) runtimeFactory {
if factory.newDB == nil {
factory.newDB = repository.NewDB
}
if factory.newRedisCache == nil {
factory.newRedisCache = cache.NewRedisCache
}
return factory
}
func initializeRuntimeExternalResources(inputs runtimeBuildInputs, factory runtimeFactory) (runtimeExternalResources, error) {
factory = withDefaultRuntimeFactory(factory)
db, err := factory.newDB(inputs.initCtx, inputs.cfg.Database)
if err != nil {
if isProd {
return nil, fmt.Errorf("database unavailable: %w", err)
if inputs.isProd {
return runtimeExternalResources{}, fmt.Errorf("database unavailable: %w", err)
}
warnf(opts.Logger, "failed to connect to database: %v (using in-memory store)", err)
warnf(inputs.logger, "failed to connect to database: %v (using in-memory store)", err)
db = nil
} else if db != nil {
infof(opts.Logger, "connected to database at %s:%d", opts.Config.Database.Host, opts.Config.Database.Port)
infof(inputs.logger, "connected to database at %s:%d", inputs.cfg.Database.Host, inputs.cfg.Database.Port)
}
redisCache, err := factory.newRedisCache(opts.Config.Redis)
redisCache, err := factory.newRedisCache(inputs.cfg.Redis)
if err != nil {
if isProd {
warnf(opts.Logger, "redis unavailable at startup: %v", err)
if inputs.isProd {
warnf(inputs.logger, "redis unavailable at startup: %v", err)
} else {
warnf(opts.Logger, "failed to connect to redis: %v (caching disabled)", err)
warnf(inputs.logger, "failed to connect to redis: %v (caching disabled)", err)
}
redisCache = nil
} else if redisCache != nil {
infof(opts.Logger, "connected to redis at %s:%d", opts.Config.Redis.Host, opts.Config.Redis.Port)
infof(inputs.logger, "connected to redis at %s:%d", inputs.cfg.Redis.Host, inputs.cfg.Redis.Port)
}
storeBundle := buildStoreBundle(db, opts.Logger)
securityBundle := buildSecurityBundle(env, opts.Config, opts.Logger, storeBundle.auditStore, redisCache, storeBundle.tokenStatusRepo)
apiBundle, err := buildAPIBundle(env, opts.Config, now, tuning, opts.Logger, isProd, storeBundle)
if err != nil {
return nil, err
}
return buildRuntimeResources(db, redisCache), nil
}
return &Runtime{
resources: runtimeExternalResources{
db: db,
redisCache: redisCache,
func buildRuntimeResources(db *repository.DB, redisCache *cache.RedisCache) runtimeExternalResources {
return runtimeExternalResources{
db: db,
redisCache: redisCache,
}
}
func buildRuntimeStartupViews(
env string,
logger logging.Logger,
serverConfig config.ServerConfig,
tuning runtimeTuning,
securityBundle runtimeSecurityBundle,
apiBundle runtimeAPIBundle,
) runtimeStartupViews {
return runtimeStartupViews{
http: runtimeHTTPStartupView{
env: env,
logger: logger,
serverConfig: normalizeServerConfig(serverConfig),
supplyAPI: apiBundle.supplyAPI,
alertAPI: apiBundle.alertAPI,
authMiddleware: securityBundle.authMiddleware,
rateLimitConfig: apiBundle.rateLimitConfig,
},
startupViews: runtimeStartupViews{
http: runtimeHTTPStartupView{
env: env,
logger: opts.Logger,
serverConfig: normalizeServerConfig(opts.Config.Server),
supplyAPI: apiBundle.supplyAPI,
alertAPI: apiBundle.alertAPI,
authMiddleware: securityBundle.authMiddleware,
rateLimitConfig: apiBundle.rateLimitConfig,
},
background: runtimeBackgroundStartupView{
env: env,
logger: opts.Logger,
tuning: tuning,
revocationSubscriber: securityBundle.revocationSubscriber,
},
background: runtimeBackgroundStartupView{
env: env,
logger: logger,
tuning: tuning,
revocationSubscriber: securityBundle.revocationSubscriber,
},
}, nil
}
}
func buildStoreBundle(db *repository.DB, logger logging.Logger) runtimeStoreBundle {

View File

@@ -198,6 +198,95 @@ func TestResolveEnv_RejectsUnsupportedValue(t *testing.T) {
}
}
func TestResolveRuntimeBuildInputs_NormalizesDefaults(t *testing.T) {
inputs, err := resolveRuntimeBuildInputs(RuntimeOptions{
Config: testRuntimeConfig(),
Logger: testLogger{},
})
if err != nil {
t.Fatalf("expected input normalization to succeed, got %v", err)
}
if inputs.cfg == nil {
t.Fatal("expected config to be preserved")
}
if inputs.logger == nil {
t.Fatal("expected logger to be preserved")
}
if inputs.env != "dev" {
t.Fatalf("unexpected env: %s", inputs.env)
}
if inputs.isProd {
t.Fatal("expected default env to be non-prod")
}
if inputs.now == nil {
t.Fatal("expected now func to be defaulted")
}
if inputs.initCtx == nil {
t.Fatal("expected init context to be defaulted")
}
if inputs.tuning.outboxStreamName != "supply:outbox:stream" {
t.Fatalf("unexpected outbox stream: %s", inputs.tuning.outboxStreamName)
}
}
func TestInitializeRuntimeExternalResources_ProdRejectsDatabaseFailure(t *testing.T) {
_, err := initializeRuntimeExternalResources(runtimeBuildInputs{
env: "prod",
cfg: testRuntimeConfig(),
logger: testLogger{},
initCtx: context.Background(),
isProd: true,
tuning: defaultRuntimeTuning(),
now: time.Now,
}, runtimeFactory{
newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) {
return nil, errors.New("db down")
},
newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) {
return nil, nil
},
})
if err == nil {
t.Fatal("expected prod runtime resources to reject database outage")
}
if !strings.Contains(err.Error(), "database unavailable") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestInitializeRuntimeExternalResources_DevFallsBackToNilResources(t *testing.T) {
logger := &captureLogger{}
resources, err := initializeRuntimeExternalResources(runtimeBuildInputs{
env: "dev",
cfg: testRuntimeConfig(),
logger: logger,
initCtx: context.Background(),
isProd: false,
tuning: defaultRuntimeTuning(),
now: time.Now,
}, runtimeFactory{
newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) {
return nil, errors.New("db down")
},
newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) {
return nil, errors.New("redis down")
},
})
if err != nil {
t.Fatalf("expected dev runtime resources to fall back, got %v", err)
}
if resources.db != nil {
t.Fatal("expected nil db after dev fallback")
}
if resources.redisCache != nil {
t.Fatal("expected nil redis cache after dev fallback")
}
if len(logger.warnMessages) == 0 {
t.Fatal("expected warning logs during dev resource fallback")
}
}
func TestBuildRuntime_RejectsUnsupportedEnv(t *testing.T) {
_, err := buildRuntimeWithFactory(RuntimeOptions{
Env: "qa",
@@ -355,6 +444,74 @@ func TestBuildRuntime_DevFallbackLogsWarnings(t *testing.T) {
}
}
func TestBuildRuntimeResources_GroupsExternalDependencies(t *testing.T) {
db := &repository.DB{}
redisCache := &cache.RedisCache{}
resources := buildRuntimeResources(db, redisCache)
if resources.db != db {
t.Fatal("expected db resource to be preserved")
}
if resources.redisCache != redisCache {
t.Fatal("expected redis cache resource to be preserved")
}
}
func TestBuildRuntimeStartupViews_GroupsHTTPAndBackgroundDependencies(t *testing.T) {
supplyAPI, alertAPI := mustBuildTestAPIs(t)
logger := testLogger{}
authMiddleware := &middleware.AuthMiddleware{}
rateLimitConfig := &middleware.RateLimitConfig{Enabled: true}
tuning := defaultRuntimeTuning()
subscriber := stubRevocationSubscriber{}
views := buildRuntimeStartupViews(
"staging",
logger,
config.ServerConfig{},
tuning,
runtimeSecurityBundle{
authMiddleware: authMiddleware,
revocationSubscriber: subscriber,
},
runtimeAPIBundle{
supplyAPI: supplyAPI,
alertAPI: alertAPI,
rateLimitConfig: rateLimitConfig,
},
)
if views.http.env != "staging" {
t.Fatalf("unexpected http env: %s", views.http.env)
}
if views.background.env != "staging" {
t.Fatalf("unexpected background env: %s", views.background.env)
}
if views.http.serverConfig.Addr != ":18082" {
t.Fatalf("unexpected default addr: %s", views.http.serverConfig.Addr)
}
if views.http.serverConfig.ShutdownTimeout != 5*time.Second {
t.Fatalf("unexpected default shutdown timeout: %s", views.http.serverConfig.ShutdownTimeout)
}
if views.http.supplyAPI != supplyAPI {
t.Fatal("expected supply api to be preserved")
}
if views.http.alertAPI != alertAPI {
t.Fatal("expected alert api to be preserved")
}
if views.http.authMiddleware != authMiddleware {
t.Fatal("expected auth middleware to be preserved")
}
if views.http.rateLimitConfig != rateLimitConfig {
t.Fatal("expected rate limit config to be preserved")
}
if views.background.revocationSubscriber != subscriber {
t.Fatal("expected revocation subscriber to be preserved")
}
if views.background.tuning.outboxStreamName != tuning.outboxStreamName {
t.Fatalf("unexpected background outbox stream: %s", views.background.tuning.outboxStreamName)
}
}
func TestBuildRuntime_GroupsResourcesAndStartupViews(t *testing.T) {
runtime, err := buildRuntimeWithFactory(RuntimeOptions{
Env: "dev",