- Restored ADR-0011 with updated implementation details - Documented go-playground/validator adoption and integration strategy - Added technical implementation examples and migration path - Updated status to 'Adopted' reflecting current usage 🔧 refactor: integrate authentication handlers with validation system - Added validation tags to all authentication request DTOs: - LoginRequest: username (3-50 chars), password (6+ chars) - RegisterRequest: username (3-50 chars), password (6-100 chars) - PasswordResetRequest: username (3-50 chars) - PasswordResetCompleteRequest: username (3-50 chars), new_password (6-100 chars) - Updated AuthHandler to accept validator parameter - Replaced manual validation with structured validator.Validate() calls - Added writeValidationError() helper for consistent error responses - Updated server to inject validator into authentication handler - Improved error messages with field-level validation details 🧪 test: update BDD tests for new validation error format - Updated authentication validation tests to expect structured errors - All 25 BDD scenarios passing with improved validation coverage - Maintained backward compatibility for error handling Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
375 lines
11 KiB
Go
375 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
|
|
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
|
|
}
|
|
|
|
// initializeUserServices initializes the user repository and unified user service
|
|
func initializeUserServices(cfg *config.Config) (user.UserRepository, user.UserService, 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, 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 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)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|