Files
Snip/backend/internal/di/container.go
T
2026-06-15 21:25:57 +07:00

161 lines
5.3 KiB
Go

// Package di wires concrete adapters into the application core. Each command
// builds only what it needs (the frontend command touches no infrastructure).
package di
import (
"context"
"log"
"net/http"
"time"
"github.com/snip/backend/internal/adapter/identity"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/adapter/postgres"
"github.com/snip/backend/internal/adapter/rediscache"
"github.com/snip/backend/internal/adapter/security"
"github.com/snip/backend/internal/config"
"github.com/snip/backend/internal/httpx/api"
"github.com/snip/backend/internal/httpx/frontend"
"github.com/snip/backend/internal/httpx/redirect"
"github.com/snip/backend/internal/port"
"github.com/snip/backend/internal/service"
)
// Container holds config and constructs per-command applications.
type Container struct {
cfg config.Config
}
func New(cfg config.Config) *Container { return &Container{cfg: cfg} }
// App is a runnable handler plus its resource closers.
type App struct {
Handler http.Handler
closers []func()
}
func (a *App) Close() {
for i := len(a.closers) - 1; i >= 0; i-- {
a.closers[i]()
}
}
type infra struct {
links port.LinkRepository
users port.UserRepository
cache port.LinkCache
closers []func()
}
// buildInfra picks the storage adapters based on STORE.
func (c *Container) buildInfra(ctx context.Context) (*infra, error) {
if c.cfg.Store == "memory" {
return &infra{links: memory.NewLinkRepo(), users: memory.NewUserRepo(), cache: memory.NewCache()}, nil
}
pool, err := postgres.Connect(ctx, c.cfg.DatabaseURL)
if err != nil {
return nil, err
}
cache, err := rediscache.New(ctx, c.cfg.RedisAddr, c.cfg.RedisPassword, c.cfg.RedisDB)
if err != nil {
pool.Close()
return nil, err
}
return &infra{
links: postgres.NewLinkRepo(pool),
users: postgres.NewUserRepo(pool),
cache: cache,
closers: []func(){pool.Close, func() { _ = cache.Close() }},
}, nil
}
// BuildFrontend constructs the static SPA server (no DB/Redis).
func (c *Container) BuildFrontend() *App {
return &App{Handler: frontend.New(c.cfg.FrontendDist).Handler()}
}
// BuildAPI constructs the /api/v1 server. OIDC provider discovery runs in the
// background so the HTTP server (and its health endpoint) starts immediately.
// Providers become available once discovery completes; until then the /auth/*
// endpoints return an empty provider list and login buttons are hidden.
func (c *Container) BuildAPI(ctx context.Context) (*App, error) {
inf, err := c.buildInfra(ctx)
if err != nil {
return nil, err
}
hasher := security.NewBcryptHasher()
sessions := security.NewHMACSessions(c.cfg.SessionSecret)
linkSvc := service.NewLinkService(inf.links, inf.cache, hasher)
authSvc := service.NewAuthService(nil, inf.users, sessions) // providers injected below
h := api.New(linkSvc, authSvc, sessions, c.cfg).Handler()
// Discover OIDC providers in the background; inject them once ready.
go func() {
providers := c.buildProviders(ctx)
authSvc.SetProviders(providers)
log.Printf("di: %d identity provider(s) ready", len(providers))
}()
return &App{Handler: h, closers: inf.closers}, nil
}
// BuildRedirect constructs the redirect server and starts the click flusher.
func (c *Container) BuildRedirect(ctx context.Context) (*App, error) {
inf, err := c.buildInfra(ctx)
if err != nil {
return nil, err
}
hasher := security.NewBcryptHasher()
recorder := service.NewAsyncClickRecorder(inf.links, c.cfg.ClickFlushInterval)
recorder.Start(ctx)
redirectSvc := service.NewRedirectService(inf.links, inf.cache, hasher, recorder)
h := redirect.New(redirectSvc, c.cfg.ShortDomain, c.cfg.PublicURL).Handler()
closers := append([]func(){recorder.Stop}, inf.closers...)
return &App{Handler: h, closers: closers}, nil
}
// buildProviders configures the available login methods. Each is optional: if
// its credentials are absent (or discovery fails) the button simply won't show.
func (c *Container) buildProviders(ctx context.Context) []port.IdentityProvider {
var providers []port.IdentityProvider
callback := func(name string) string {
return c.cfg.PublicURL + "/api/v1/auth/" + name + "/callback"
}
if c.cfg.GoogleClientID != "" && c.cfg.GoogleClientSecret != "" {
if p := dialProvider(ctx, "google", identity.GoogleIssuer,
c.cfg.GoogleClientID, c.cfg.GoogleClientSecret, callback("google")); p != nil {
providers = append(providers, p)
}
}
if c.cfg.OIDCIssuer != "" && c.cfg.OIDCClientID != "" {
if p := dialProvider(ctx, "oidc", c.cfg.OIDCIssuer,
c.cfg.OIDCClientID, c.cfg.OIDCClientSecret, callback("oidc")); p != nil {
providers = append(providers, p)
}
}
return providers
}
// dialProvider performs OIDC discovery with a short retry, so the api command
// can start alongside a still-booting (mock) provider instead of silently
// disabling login on a transient connection error.
func dialProvider(ctx context.Context, name, issuer, clientID, secret, redirect string) port.IdentityProvider {
var lastErr error
for attempt := 1; attempt <= 15; attempt++ {
p, err := identity.NewOIDCProvider(ctx, name, issuer, clientID, secret, redirect, nil)
if err == nil {
log.Printf("di: %s provider ready (issuer %s)", name, issuer)
return p
}
lastErr = err
select {
case <-ctx.Done():
return nil
case <-time.After(2 * time.Second):
}
}
log.Printf("di: %s provider disabled after retries: %v", name, lastErr)
return nil
}