Refactor backend code to remove duplicate functions and factorize service startup

master
oabrivard 1 month ago
parent 7505a4256e
commit c03ae3f0f0

@ -3,17 +3,20 @@ package main
import (
"log"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/infra/utils/serviceboot"
)
func main() {
app := fiber.New(fiber.Config{
AppName: "Know Foolery - Admin Service",
})
cfg := serviceboot.Config{
AppName: "Know Foolery - Admin Service",
ServiceSlug: "admin",
PortEnv: "ADMIN_SERVICE_PORT",
DefaultPort: 8085,
}
app.Get("/health", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "healthy", "service": "admin"})
})
app := serviceboot.NewFiberApp(cfg)
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
log.Fatal(app.Listen(":8085"))
addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort)
log.Fatal(serviceboot.Run(app, addr))
}

@ -2,7 +2,10 @@ module knowfoolery/backend/services/admin-service
go 1.25.5
require github.com/gofiber/fiber/v3 v3.0.0-beta.3
require (
github.com/gofiber/fiber/v3 v3.0.0-beta.3
knowfoolery/backend/shared v0.0.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect

@ -3,17 +3,20 @@ package main
import (
"log"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/infra/utils/serviceboot"
)
func main() {
app := fiber.New(fiber.Config{
AppName: "Know Foolery - Game Session Service",
})
cfg := serviceboot.Config{
AppName: "Know Foolery - Game Session Service",
ServiceSlug: "game-session",
PortEnv: "GAME_SESSION_PORT",
DefaultPort: 8080,
}
app.Get("/health", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "healthy", "service": "game-session"})
})
app := serviceboot.NewFiberApp(cfg)
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
log.Fatal(app.Listen(":8080"))
addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort)
log.Fatal(serviceboot.Run(app, addr))
}

@ -2,7 +2,10 @@ module knowfoolery/backend/services/game-session-service
go 1.25.5
require github.com/gofiber/fiber/v3 v3.0.0-beta.3
require (
github.com/gofiber/fiber/v3 v3.0.0-beta.3
knowfoolery/backend/shared v0.0.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect

@ -3,17 +3,20 @@ package main
import (
"log"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/infra/utils/serviceboot"
)
func main() {
app := fiber.New(fiber.Config{
AppName: "Know Foolery - Gateway Service",
})
cfg := serviceboot.Config{
AppName: "Know Foolery - Gateway Service",
ServiceSlug: "gateway",
PortEnv: "GATEWAY_PORT",
DefaultPort: 8086,
}
app.Get("/health", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "healthy", "service": "gateway"})
})
app := serviceboot.NewFiberApp(cfg)
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
log.Fatal(app.Listen(":8086"))
addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort)
log.Fatal(serviceboot.Run(app, addr))
}

@ -2,7 +2,10 @@ module knowfoolery/backend/services/gateway-service
go 1.25.5
require github.com/gofiber/fiber/v3 v3.0.0-beta.3
require (
github.com/gofiber/fiber/v3 v3.0.0-beta.3
knowfoolery/backend/shared v0.0.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect

@ -3,17 +3,20 @@ package main
import (
"log"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/infra/utils/serviceboot"
)
func main() {
app := fiber.New(fiber.Config{
AppName: "Know Foolery - Leaderboard Service",
})
cfg := serviceboot.Config{
AppName: "Know Foolery - Leaderboard Service",
ServiceSlug: "leaderboard",
PortEnv: "LEADERBOARD_PORT",
DefaultPort: 8083,
}
app.Get("/health", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "healthy", "service": "leaderboard"})
})
app := serviceboot.NewFiberApp(cfg)
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
log.Fatal(app.Listen(":8083"))
addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort)
log.Fatal(serviceboot.Run(app, addr))
}

@ -2,7 +2,10 @@ module knowfoolery/backend/services/leaderboard-service
go 1.25.5
require github.com/gofiber/fiber/v3 v3.0.0-beta.3
require (
github.com/gofiber/fiber/v3 v3.0.0-beta.3
knowfoolery/backend/shared v0.0.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect

@ -3,17 +3,20 @@ package main
import (
"log"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/infra/utils/serviceboot"
)
func main() {
app := fiber.New(fiber.Config{
AppName: "Know Foolery - Question Bank Service",
})
cfg := serviceboot.Config{
AppName: "Know Foolery - Question Bank Service",
ServiceSlug: "question-bank",
PortEnv: "QUESTION_BANK_PORT",
DefaultPort: 8081,
}
app.Get("/health", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "healthy", "service": "question-bank"})
})
app := serviceboot.NewFiberApp(cfg)
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
log.Fatal(app.Listen(":8081"))
addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort)
log.Fatal(serviceboot.Run(app, addr))
}

@ -2,7 +2,10 @@ module knowfoolery/backend/services/question-bank-service
go 1.25.5
require github.com/gofiber/fiber/v3 v3.0.0-beta.3
require (
github.com/gofiber/fiber/v3 v3.0.0-beta.3
knowfoolery/backend/shared v0.0.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect

@ -3,17 +3,20 @@ package main
import (
"log"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/infra/utils/serviceboot"
)
func main() {
app := fiber.New(fiber.Config{
AppName: "Know Foolery - User Service",
})
cfg := serviceboot.Config{
AppName: "Know Foolery - User Service",
ServiceSlug: "user",
PortEnv: "USER_SERVICE_PORT",
DefaultPort: 8082,
}
app.Get("/health", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "healthy", "service": "user"})
})
app := serviceboot.NewFiberApp(cfg)
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
log.Fatal(app.Listen(":8082"))
addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort)
log.Fatal(serviceboot.Run(app, addr))
}

@ -2,7 +2,10 @@ module knowfoolery/backend/services/user-service
go 1.25.5
require github.com/gofiber/fiber/v3 v3.0.0-beta.3
require (
github.com/gofiber/fiber/v3 v3.0.0-beta.3
knowfoolery/backend/shared v0.0.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect

@ -5,11 +5,11 @@ import (
"context"
"errors"
"fmt"
"os"
"strconv"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"knowfoolery/backend/shared/infra/utils/envutil"
)
// Config holds the configuration for the PostgreSQL client.
@ -95,17 +95,17 @@ func HealthCheck(ctx context.Context, config Config) error {
func ConfigFromEnv() Config {
cfg := DefaultConfig()
cfg.Host = getenvString("POSTGRES_HOST", cfg.Host)
cfg.Port = getenvInt("POSTGRES_PORT", cfg.Port)
cfg.User = getenvString("POSTGRES_USER", cfg.User)
cfg.Password = getenvString("POSTGRES_PASSWORD", cfg.Password)
cfg.Database = getenvString("POSTGRES_DB", cfg.Database)
cfg.SSLMode = getenvString("POSTGRES_SSLMODE", cfg.SSLMode)
cfg.Host = envutil.String("POSTGRES_HOST", cfg.Host)
cfg.Port = envutil.Int("POSTGRES_PORT", cfg.Port)
cfg.User = envutil.String("POSTGRES_USER", cfg.User)
cfg.Password = envutil.String("POSTGRES_PASSWORD", cfg.Password)
cfg.Database = envutil.String("POSTGRES_DB", cfg.Database)
cfg.SSLMode = envutil.String("POSTGRES_SSLMODE", cfg.SSLMode)
cfg.MaxOpenConns = getenvInt("POSTGRES_MAX_OPEN_CONNS", cfg.MaxOpenConns)
cfg.MaxIdleConns = getenvInt("POSTGRES_MAX_IDLE_CONNS", cfg.MaxIdleConns)
cfg.ConnMaxLifetime = getenvDuration("POSTGRES_CONN_MAX_LIFETIME", cfg.ConnMaxLifetime)
cfg.ConnMaxIdleTime = getenvDuration("POSTGRES_CONN_MAX_IDLE_TIME", cfg.ConnMaxIdleTime)
cfg.MaxOpenConns = envutil.Int("POSTGRES_MAX_OPEN_CONNS", cfg.MaxOpenConns)
cfg.MaxIdleConns = envutil.Int("POSTGRES_MAX_IDLE_CONNS", cfg.MaxIdleConns)
cfg.ConnMaxLifetime = envutil.Duration("POSTGRES_CONN_MAX_LIFETIME", cfg.ConnMaxLifetime)
cfg.ConnMaxIdleTime = envutil.Duration("POSTGRES_CONN_MAX_IDLE_TIME", cfg.ConnMaxIdleTime)
return cfg
}
@ -123,38 +123,3 @@ func validateConfig(c Config) error {
}
return nil
}
func getenvString(key string, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getenvInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
i, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return i
}
func getenvDuration(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return d
}

@ -5,11 +5,11 @@ import (
"context"
"errors"
"fmt"
"os"
"strconv"
"time"
redisv9 "github.com/redis/go-redis/v9"
"knowfoolery/backend/shared/infra/utils/envutil"
)
// Config holds the configuration for the Redis client.
@ -131,15 +131,15 @@ func (c *Client) Expire(ctx context.Context, key string, expiration time.Duratio
func ConfigFromEnv() Config {
cfg := DefaultConfig()
cfg.Host = getenvString("REDIS_HOST", cfg.Host)
cfg.Port = getenvInt("REDIS_PORT", cfg.Port)
cfg.Password = getenvString("REDIS_PASSWORD", cfg.Password)
cfg.DB = getenvInt("REDIS_DB", cfg.DB)
cfg.PoolSize = getenvInt("REDIS_POOL_SIZE", cfg.PoolSize)
cfg.MinIdleConns = getenvInt("REDIS_MIN_IDLE_CONNS", cfg.MinIdleConns)
cfg.DialTimeout = getenvDuration("REDIS_DIAL_TIMEOUT", cfg.DialTimeout)
cfg.ReadTimeout = getenvDuration("REDIS_READ_TIMEOUT", cfg.ReadTimeout)
cfg.WriteTimeout = getenvDuration("REDIS_WRITE_TIMEOUT", cfg.WriteTimeout)
cfg.Host = envutil.String("REDIS_HOST", cfg.Host)
cfg.Port = envutil.Int("REDIS_PORT", cfg.Port)
cfg.Password = envutil.String("REDIS_PASSWORD", cfg.Password)
cfg.DB = envutil.Int("REDIS_DB", cfg.DB)
cfg.PoolSize = envutil.Int("REDIS_POOL_SIZE", cfg.PoolSize)
cfg.MinIdleConns = envutil.Int("REDIS_MIN_IDLE_CONNS", cfg.MinIdleConns)
cfg.DialTimeout = envutil.Duration("REDIS_DIAL_TIMEOUT", cfg.DialTimeout)
cfg.ReadTimeout = envutil.Duration("REDIS_READ_TIMEOUT", cfg.ReadTimeout)
cfg.WriteTimeout = envutil.Duration("REDIS_WRITE_TIMEOUT", cfg.WriteTimeout)
return cfg
}
@ -163,36 +163,3 @@ func validateConfig(c Config) error {
}
return nil
}
func getenvString(key string, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getenvInt(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
i, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return i
}
func getenvDuration(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return d
}

@ -0,0 +1,46 @@
// Package envutil provides environment variable parsing helpers with fallback values.
package envutil
import (
"os"
"strconv"
"time"
)
// String returns the environment variable value or fallback when empty.
func String(key string, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// Int returns the parsed integer environment variable or fallback on parse error.
func Int(key string, fallback int) int {
v := os.Getenv(key)
if v == "" {
return fallback
}
i, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return i
}
// Duration returns the parsed duration environment variable or fallback on parse error.
func Duration(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
return fallback
}
return d
}

@ -0,0 +1,38 @@
package envutil
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestString(t *testing.T) {
t.Setenv("APP_VALUE", "configured")
require.Equal(t, "configured", String("APP_VALUE", "fallback"))
}
func TestStringFallback(t *testing.T) {
t.Setenv("APP_VALUE", "")
require.Equal(t, "fallback", String("APP_VALUE", "fallback"))
}
func TestInt(t *testing.T) {
t.Setenv("APP_PORT", "8080")
require.Equal(t, 8080, Int("APP_PORT", 3000))
}
func TestIntFallbackInvalid(t *testing.T) {
t.Setenv("APP_PORT", "oops")
require.Equal(t, 3000, Int("APP_PORT", 3000))
}
func TestDuration(t *testing.T) {
t.Setenv("APP_TIMEOUT", "7s")
require.Equal(t, 7*time.Second, Duration("APP_TIMEOUT", time.Second))
}
func TestDurationFallbackInvalid(t *testing.T) {
t.Setenv("APP_TIMEOUT", "oops")
require.Equal(t, 2*time.Second, Duration("APP_TIMEOUT", 2*time.Second))
}

@ -0,0 +1,52 @@
// Package serviceboot provides shared service bootstrap helpers.
package serviceboot
import (
"fmt"
"github.com/gofiber/fiber/v3"
"knowfoolery/backend/shared/infra/utils/envutil"
)
// Config defines basic service runtime settings.
type Config struct {
AppName string
ServiceSlug string
PortEnv string
DefaultPort int
}
// NewFiberApp creates a Fiber app with shared defaults.
func NewFiberApp(cfg Config) *fiber.App {
return fiber.New(fiber.Config{
AppName: cfg.AppName,
})
}
// RegisterHealth registers a standard health endpoint.
func RegisterHealth(app *fiber.App, serviceSlug string) {
app.Get("/health", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "healthy",
"service": serviceSlug,
})
})
}
// ListenAddress resolves the listen port from environment with fallback.
func ListenAddress(portEnv string, defaultPort int) string {
port := envutil.Int(portEnv, defaultPort)
if port < 1 || port > 65535 {
port = defaultPort
}
if port < 1 || port > 65535 {
port = 8080
}
return fmt.Sprintf(":%d", port)
}
// Run starts the Fiber app.
func Run(app *fiber.App, addr string) error {
return app.Listen(addr)
}

@ -0,0 +1,40 @@
package serviceboot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestRegisterHealth(t *testing.T) {
app := NewFiberApp(Config{AppName: "test-service"})
RegisterHealth(app, "svc")
req := httptest.NewRequest(http.MethodGet, "/health", nil)
resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()
var body map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, "healthy", body["status"])
require.Equal(t, "svc", body["service"])
}
func TestListenAddressFromEnv(t *testing.T) {
t.Setenv("SERVICE_PORT", "9090")
require.Equal(t, ":9090", ListenAddress("SERVICE_PORT", 8080))
}
func TestListenAddressFallback(t *testing.T) {
t.Setenv("SERVICE_PORT", "bad")
require.Equal(t, ":8080", ListenAddress("SERVICE_PORT", 8080))
}
func TestListenAddressOutOfRangeFallback(t *testing.T) {
t.Setenv("SERVICE_PORT", "70000")
require.Equal(t, ":8080", ListenAddress("SERVICE_PORT", 8080))
}

@ -973,6 +973,10 @@ cd backend/services/{service-name}
# Start service in development mode with hot reload
go run cmd/main.go
# Service ports are env-driven via shared bootstrap helpers:
# ADMIN_SERVICE_PORT, GAME_SESSION_PORT, GATEWAY_PORT,
# LEADERBOARD_PORT, QUESTION_BANK_PORT, USER_SERVICE_PORT
# Run tests for a specific service
go test ./... -v

@ -168,11 +168,11 @@ backend/shared/
- `backend/shared/infra/database/redis/client.go`
**Postgres implementation:**
- Add dependency: `github.com/jackc/pgx/v5/pgxpool`
- Replace placeholder client with wrapper around `*pgxpool.Pool`
- Implement `NewClient(ctx, cfg)` with config validation
- Implement `Ping`, `Close`, `HealthCheck`
- Keep shared package minimal: config/env parsing and connection string helpers
- Implement `ConfigFromEnv` using `os.Getenv` with defaults
- Add lightweight `HealthCheck` helper for connectivity validation
- Do **not** introduce a shared runtime Postgres data client
- Each service initializes and manages Ent directly for Postgres access
**Redis implementation:**
- Add dependency: `github.com/redis/go-redis/v9`
@ -181,8 +181,8 @@ backend/shared/
- Implement `ConfigFromEnv` using `os.Getenv` with defaults
**Acceptance criteria:**
- Clients connect successfully via `ConfigFromEnv`
- Health checks are real and return errors on failure
- Postgres helper is minimal and does not duplicate Ent responsibilities
- Postgres and Redis health checks are real and return errors on failure
- Redis CRUD methods implemented and tested
---
@ -268,7 +268,7 @@ backend/shared/
- `infra/auth/zitadel.Client.ValidateToken` becomes fully implemented with JWKS validation
- `infra/auth/zitadel.Client.RefreshToken` and `RevokeToken` implemented
- `infra/database/postgres.Client` wraps `*pgxpool.Pool`
- `infra/database/postgres` remains a minimal helper package (config/env + health check)
- `infra/database/redis.Client` wraps `*redis.Client`
- `infra/observability/tracing.Tracer` uses OpenTelemetry and returns real spans
- New helper in metrics: `metrics.Handler()` or `metrics.PrometheusHandler()`
@ -280,7 +280,7 @@ backend/shared/
Add or extend unit tests for:
1. JWT validation with JWKS (mocked discovery)
2. MFA detection via `amr` claim
3. Postgres config validation and connection errors
3. Postgres config parsing and health check error behavior
4. Redis CRUD behavior (use `miniredis` or mocked client)
5. Tracing initialization and shutdown behavior
6. Metrics handler creation

Loading…
Cancel
Save