Files
dance-lessons-coach/pkg/server/server.go

313 lines
8.7 KiB
Go

//go:generate swag init -g ../../cmd/server/main.go --parseDependency --parseInternal
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"
"DanceLessonsCoach/pkg/config"
"DanceLessonsCoach/pkg/greet"
"DanceLessonsCoach/pkg/telemetry"
"DanceLessonsCoach/pkg/validation"
"DanceLessonsCoach/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
}
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")
}
s := &Server{
router: chi.NewRouter(),
readyCtx: readyCtx,
withOTEL: cfg.GetTelemetryEnabled(),
config: cfg,
validator: validator,
}
s.setupRoutes()
return s
}
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)
r.Route("/greet", func(r chi.Router) {
greetHandler.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(),
}
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
}