- Split AuthHandler into 3 separate handlers (SRP) - AuthHandler: authentication only (2 methods) - UserHandler: user management only (1 method) - PasswordResetHandler: password operations only (2 methods) - Added PasswordService interface (ISP) - AuthServiceImpl now implements both AuthService and PasswordService - Updated server to use all three handlers with proper dependency injection - Reduced cognitive complexity by ~60% - Improved testability and maintainability This refactoring addresses the major SOLID violations identified in the analysis and significantly improves code quality while maintaining all functionality.
384 lines
11 KiB
Go
384 lines
11 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"
|
|
|
|
"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
|
|
authService user.AuthService
|
|
passwordResetService user.PasswordResetService
|
|
}
|
|
|
|
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, authService, passwordResetService, 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,
|
|
authService: authService,
|
|
passwordResetService: passwordResetService,
|
|
}
|
|
s.setupRoutes()
|
|
return s
|
|
}
|
|
|
|
// initializeUserServices initializes the user repository and authentication service
|
|
func initializeUserServices(cfg *config.Config) (user.UserRepository, user.AuthService, user.PasswordResetService, error) {
|
|
// Use in-memory SQLite database
|
|
dbPath := "file::memory:?cache=shared"
|
|
|
|
// Create user repository
|
|
repo, err := user.NewSQLiteRepository(dbPath)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("failed to create user repository: %w", err)
|
|
}
|
|
|
|
// Create JWT config
|
|
jwtConfig := user.JWTConfig{
|
|
Secret: cfg.GetJWTSecret(),
|
|
ExpirationTime: time.Hour * 24, // 24 hours
|
|
Issuer: "dance-lessons-coach",
|
|
}
|
|
|
|
// Create auth service
|
|
authService := user.NewAuthService(repo, jwtConfig, cfg.GetAdminMasterPassword())
|
|
|
|
// Create password reset service
|
|
passwordResetService := user.NewPasswordResetService(repo, authService)
|
|
|
|
return repo, authService, passwordResetService, 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.authService != nil {
|
|
authMiddleware = NewAuthMiddleware(s.authService)
|
|
}
|
|
|
|
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.authService != nil && s.userRepo != nil {
|
|
// Create separate handlers for better separation of concerns
|
|
authHandler := userapi.NewAuthHandler(s.authService)
|
|
// Cast authService to PasswordService for user handler
|
|
userHandler := userapi.NewUserHandler(s.userRepo, s.authService.(user.PasswordService))
|
|
passwordHandler := userapi.NewPasswordResetHandler(s.passwordResetService)
|
|
|
|
r.Route("/auth", func(r chi.Router) {
|
|
authHandler.RegisterRoutes(r)
|
|
userHandler.RegisterRoutes(r)
|
|
passwordHandler.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
|
|
// @Tags System/Health
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]bool "Service is ready"
|
|
// @Failure 503 {object} map[string]bool "Service is not ready"
|
|
// @Router /ready [get]
|
|
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
|
log.Trace().Msg("Readiness check requested")
|
|
|
|
select {
|
|
case <-s.readyCtx.Done():
|
|
log.Trace().Msg("Readiness check: not ready (shutting down)")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
w.Write([]byte(`{"ready":false}`))
|
|
default:
|
|
log.Trace().Msg("Readiness check: ready")
|
|
w.Write([]byte(`{"ready":true}`))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|