Files
dance-lessons-coach/pkg/server/server.go
Gabriel Radureau 5eec64e5e8
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m15s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped
🧪 test: add JWT secret rotation BDD scenarios and step implementations (#12)
 merge: implement JWT secret rotation with BDD scenario isolation

- Implement JWT secret rotation mechanism (closes #8)
- Add per-scenario state isolation for BDD tests (closes #14)
- Validate password reset workflow via BDD tests (closes #7)
- Fix port conflicts in test validation
- Add state tracer for debugging test execution
- Document BDD isolation strategies in ADR 0025
- Fix PostgreSQL configuration environment variables

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-04-11 17:56:45 +02:00

436 lines
12 KiB
Go

//go:generate swag init -g ../../cmd/server/main.go -d ../../pkg/greet,../../pkg/server --parseDependency && mv ../../docs/* ./docs/
package server
import (
"context"
"embed"
"fmt"
"net"
"net/http"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog/log"
httpSwagger "github.com/swaggo/http-swagger"
"dance-lessons-coach/pkg/config"
"dance-lessons-coach/pkg/greet"
"dance-lessons-coach/pkg/telemetry"
"dance-lessons-coach/pkg/user"
userapi "dance-lessons-coach/pkg/user/api"
"dance-lessons-coach/pkg/validation"
"dance-lessons-coach/pkg/version"
"encoding/json"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
//go:embed docs/swagger.json
var swaggerJSON embed.FS
type Server struct {
router *chi.Mux
readyCtx context.Context
withOTEL bool
config *config.Config
tracerProvider *sdktrace.TracerProvider
validator *validation.Validator
userRepo user.UserRepository
userService user.UserService
}
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
// Create validator instance
validator, err := validation.GetValidatorFromConfig(cfg)
if err != nil {
log.Error().Err(err).Msg("Failed to create validator, continuing without validation")
} else {
log.Trace().Msg("Validator created successfully")
}
// Initialize user repository and services
userRepo, userService, err := initializeUserServices(cfg)
if err != nil {
log.Warn().Err(err).Msg("Failed to initialize user services, user functionality will be disabled")
}
s := &Server{
router: chi.NewRouter(),
readyCtx: readyCtx,
withOTEL: cfg.GetTelemetryEnabled(),
config: cfg,
validator: validator,
userRepo: userRepo,
userService: userService,
}
s.setupRoutes()
return s
}
// GetAuthService returns the auth service for test cleanup
// This allows test suites to reset JWT secrets between tests
func (s *Server) GetAuthService() user.AuthService {
return s.userService
}
// initializeUserServices initializes the user repository and unified user service
func initializeUserServices(cfg *config.Config) (user.UserRepository, user.UserService, error) {
// Create user repository using PostgreSQL
repo, err := user.NewPostgresRepository(cfg)
if err != nil {
return nil, nil, fmt.Errorf("failed to create PostgreSQL user repository: %w", err)
}
// Create JWT config
jwtConfig := user.JWTConfig{
Secret: cfg.GetJWTSecret(),
ExpirationTime: time.Hour * 24, // 24 hours
Issuer: "dance-lessons-coach",
}
// Create unified user service
userService := user.NewUserService(repo, jwtConfig, cfg.GetAdminMasterPassword())
return repo, userService, nil
}
func (s *Server) setupRoutes() {
// Use Zerolog middleware instead of Chi's default logger
s.router.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{
Logger: &log.Logger,
NoColor: false,
}))
// Health endpoint at root level
s.router.Get("/api/health", s.handleHealth)
// Readiness endpoint at root level
s.router.Get("/api/ready", s.handleReadiness)
// Version endpoint at root level
s.router.Get("/api/version", s.handleVersion)
// API routes
s.router.Route("/api/v1", func(r chi.Router) {
r.Use(s.getAllMiddlewares()...)
s.registerApiV1Routes(r)
})
// Register v2 routes if enabled
if s.config.GetV2Enabled() {
s.router.Route("/api/v2", func(r chi.Router) {
r.Use(s.getAllMiddlewares()...)
s.registerApiV2Routes(r)
})
}
// Add Swagger UI with embedded spec
// Serve the embedded swagger.json file
s.router.Handle("/swagger/doc.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, err := swaggerJSON.ReadFile("docs/swagger.json")
if err != nil {
log.Error().Err(err).Msg("Failed to read embedded swagger.json")
http.Error(w, "Failed to read swagger.json", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}))
// Setup Swagger UI handler
s.router.Get("/swagger/*", httpSwagger.WrapHandler)
}
func (s *Server) registerApiV1Routes(r chi.Router) {
greetService := greet.NewService()
greetHandler := greet.NewApiV1GreetHandler(greetService)
// Create auth middleware if available
var authMiddleware *AuthMiddleware
if s.userService != nil {
authMiddleware = NewAuthMiddleware(s.userService)
}
r.Route("/greet", func(r chi.Router) {
// Add optional authentication middleware
if authMiddleware != nil {
r.Use(authMiddleware.Middleware)
}
greetHandler.RegisterRoutes(r)
})
// Register user authentication routes
if s.userService != nil && s.userRepo != nil {
// Use unified user service - much simpler!
if s.userService != nil {
handler := userapi.NewAuthHandler(s.userService, s.userService, s.validator)
r.Route("/auth", func(r chi.Router) {
handler.RegisterRoutes(r)
})
// Register admin routes
adminHandler := userapi.NewAdminHandler(s.userService)
r.Route("/admin", func(r chi.Router) {
adminHandler.RegisterRoutes(r)
})
}
}
}
func (s *Server) registerApiV2Routes(r chi.Router) {
greetServiceV2 := greet.NewServiceV2()
greetHandlerV2 := greet.NewApiV2GreetHandler(greetServiceV2, s.validator)
r.Route("/greet", func(r chi.Router) {
greetHandlerV2.RegisterRoutes(r)
})
}
// getAllMiddlewares returns all middleware including OpenTelemetry if enabled
func (s *Server) getAllMiddlewares() []func(http.Handler) http.Handler {
middlewares := []func(http.Handler) http.Handler{
middleware.StripSlashes,
middleware.Recoverer,
}
if s.withOTEL {
middlewares = append(middlewares, func(next http.Handler) http.Handler {
return otelhttp.NewHandler(next, "")
})
}
return middlewares
}
// handleHealth godoc
//
// @Summary Health check
// @Description Check if the service is healthy
// @Tags System/Health
// @Accept json
// @Produce json
// @Success 200 {object} map[string]string "Service is healthy"
// @Router /health [get]
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
log.Trace().Msg("Health check requested")
w.Write([]byte(`{"status":"healthy"}`))
}
// handleReadiness godoc
//
// @Summary Readiness check
// @Description Check if the service is ready to accept traffic including detailed connection status
// @Tags System/Health
// @Accept json
// @Produce json
// @Success 200 {object} object "Service is ready with connection details"
// @Failure 503 {object} object "Service is not ready with failure details"
// @Router /ready [get]
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
log.Trace().Msg("Readiness check requested")
// Check if server is shutting down
select {
case <-s.readyCtx.Done():
log.Trace().Msg("Readiness check: not ready (shutting down)")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"ready": false,
"reason": "server_shutting_down",
"connections": map[string]interface{}{
"database": "not_checked",
},
})
return
default:
// Server is not shutting down, check all connections
connectionStatus := make(map[string]interface{})
allHealthy := true
var failureReason string
// Check database if available
if s.userRepo != nil {
if err := s.userRepo.CheckDatabaseHealth(r.Context()); err != nil {
log.Warn().Err(err).Msg("Database health check failed")
connectionStatus["database"] = map[string]interface{}{
"status": "unhealthy",
"error": err.Error(),
}
allHealthy = false
failureReason = "database_unhealthy"
} else {
connectionStatus["database"] = map[string]interface{}{
"status": "healthy",
}
}
} else {
connectionStatus["database"] = map[string]interface{}{
"status": "not_configured",
}
}
if allHealthy {
log.Trace().Msg("Readiness check: ready")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"ready": true,
"connections": connectionStatus,
})
} else {
log.Warn().Str("reason", failureReason).Msg("Readiness check: not ready")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"ready": false,
"reason": failureReason,
"connections": connectionStatus,
})
}
}
}
// handleVersion godoc
//
// @Summary Get API version
// @Description Returns the API version information
// @Tags System/Version
// @Accept plain,json
// @Produce plain,json
// @Param format query string false "Response format (plain, full, json)" Enums(plain, full, json) default(plain)
// @Success 200 {string} string "Version information"
// @Router /version [get]
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
log.Trace().Msg("Version check requested")
// Get format parameter
format := r.URL.Query().Get("format")
if format == "" {
format = "plain" // default format
}
switch format {
case "plain":
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(version.Short()))
case "full":
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(version.Full()))
case "json":
w.Header().Set("Content-Type", "application/json")
jsonResponse := fmt.Sprintf(`{
"version": "%s",
"commit": "%s",
"built": "%s",
"go": "%s"
}`, version.Version, version.Commit, version.Date, version.GoVersion)
w.Write([]byte(jsonResponse))
default:
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(version.Short()))
}
}
func (s *Server) Router() http.Handler {
return s.router
}
// Run starts the HTTP server and handles graceful shutdown
func (s *Server) Run() error {
// Initialize OpenTelemetry if enabled
var err error
if s.withOTEL {
log.Trace().Msg("Initializing OpenTelemetry tracing")
telemetrySetup := &telemetry.Setup{
ServiceName: s.config.GetServiceName(),
OTLPEndpoint: s.config.GetOTLPEndpoint(),
Insecure: s.config.GetTelemetryInsecure(),
SamplerType: s.config.GetSamplerType(),
SamplerRatio: s.config.GetSamplerRatio(),
Version: version.Short(),
}
if s.tracerProvider, err = telemetrySetup.InitializeTracing(context.Background()); err != nil {
log.Error().Err(err).Msg("Failed to initialize OpenTelemetry, continuing without tracing")
s.withOTEL = false
} else {
log.Trace().Msg("OpenTelemetry tracing initialized successfully")
}
}
// Setup signal context for graceful shutdown
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Create ongoing context for active requests
ongoingCtx, stopOngoingGracefully := context.WithCancel(context.Background())
defer stopOngoingGracefully()
// Create HTTP server
log.Trace().Str("address", s.config.GetServerAddress()).Msg("Server running")
srv := &http.Server{
Addr: s.config.GetServerAddress(),
Handler: s.router,
BaseContext: func(_ net.Listener) context.Context {
return ongoingCtx
},
}
// Start the HTTP server in a separate goroutine
serverErrors := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErrors <- err
}
close(serverErrors)
}()
// Wait for signal
<-rootCtx.Done()
stop()
log.Trace().Msg("Shutdown signal received")
// Cancel readiness context to stop accepting new requests
if cancelReady, ok := s.readyCtx.(interface{ Cancel() }); ok {
cancelReady.Cancel()
}
log.Trace().Msg("Readiness set to false, no longer accepting new requests")
// Give time for readiness check to propagate
time.Sleep(1 * time.Second)
log.Trace().Msg("Readiness check propagated, now waiting for ongoing requests to finish.")
// Create shutdown context with timeout from config
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), s.config.Shutdown.Timeout)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Error().Err(err).Msg("Server shutdown failed")
return err
}
log.Trace().Msg("Server shutdown complete")
// Shutdown OpenTelemetry tracer provider
if s.tracerProvider != nil {
if err := telemetry.Shutdown(context.Background(), s.tracerProvider); err != nil {
log.Error().Err(err).Msg("Failed to shutdown OpenTelemetry tracer provider")
} else {
log.Trace().Msg("OpenTelemetry tracer provider shutdown complete")
}
}
// Return any server errors
if err, ok := <-serverErrors; ok {
return err
}
return nil
}