//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 // CancelableContext wraps a context.Context and exposes a Cancel() method so // that Server.Run() can cancel readiness during graceful shutdown via the type // assertion it already performs. Callers that don't need controlled cancellation // (tests, CLI) can pass a plain context.Background() — the assertion silently // fails and readiness is never explicitly cancelled, which is harmless. type CancelableContext struct { context.Context cancel context.CancelFunc } // NewCancelableContext creates a CancelableContext whose Cancel() method will // be invoked by Server.Run() at the start of graceful shutdown, before the // 1-second readiness propagation window. The returned CancelFunc is a no-op // after Cancel() has been called, so it is safe to defer in main. func NewCancelableContext(parent context.Context) (*CancelableContext, context.CancelFunc) { ctx, cancel := context.WithCancel(parent) return &CancelableContext{Context: ctx, cancel: cancel}, cancel } // Cancel satisfies the interface checked in Run() and cancels the context. func (c *CancelableContext) Cancel() { c.cancel() } 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 startedAt time.Time } 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, startedAt: time.Now(), } 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) // Kubernetes-style health endpoint at root level s.router.Get("/api/healthz", s.handleHealthz) // 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())) } } // HealthzResponse represents the Kubernetes-style health check response type HealthzResponse struct { Status string `json:"status"` Version string `json:"version"` UptimeSeconds int64 `json:"uptime_seconds"` Timestamp time.Time `json:"timestamp"` } // handleHealthz godoc // // @Summary Kubernetes-style health check // @Description Returns rich health info for liveness/readiness probes // @Tags System/Health // @Produce json // @Success 200 {object} HealthzResponse // @Router /healthz [get] func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { log.Trace().Msg("Healthz check requested") resp := HealthzResponse{ Status: "healthy", Version: version.Version, UptimeSeconds: int64(time.Since(s.startedAt).Seconds()), Timestamp: time.Now().UTC(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } 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 }