Two changes in one diff because they share the same surface (JWTConfig plumbing): 1. **Bug fix** : pkg/server/server.go was hardcoding ExpirationTime to 24h, ignoring the auth.jwt.ttl config value entirely (default 1h). Production has been signing tokens with 24h TTL regardless of config since the config field was added. 2. **Hot-reload (ADR-0023 Phase 2)** : extends JWTConfig with a GetTTL func() time.Duration callback. effectiveTTL() prefers GetTTL when set, falls back to ExpirationTime otherwise (test-friendly). server.go wires GetTTL = cfg.GetJWTTTL — a method value that captures the *Config, so when WatchAndApply re-unmarshals, the next token generation reads the new TTL automatically. Tokens already issued keep their original expiry. WatchAndApply now also logs the new jwt_ttl on every reload event. Tests: - New TestWatchAndApply_JWTTTL in pkg/config/config_hot_reload_test.go rewrites the config file and asserts the in-memory ttl flips within 2s. Polling (no fixed sleep), race-clean. - Existing pkg/user tests (including JWT manager + cleanup loop) all pass with -race. - Full BDD suite (auth/config/greet/health/info/jwt) green. ADR-0023 status: Phase 1+2 Implemented. Phase 3 (telemetry sampler) and Phase 4 (api.v2_enabled — needs router refactor) remain Proposed.
782 lines
24 KiB
Go
782 lines
24 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"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/rs/zerolog/log"
|
|
httpSwagger "github.com/swaggo/http-swagger"
|
|
|
|
"dance-lessons-coach/pkg/cache"
|
|
"dance-lessons-coach/pkg/config"
|
|
"dance-lessons-coach/pkg/greet"
|
|
"dance-lessons-coach/pkg/middleware"
|
|
"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
|
|
cacheService cache.Service
|
|
startedAt time.Time
|
|
}
|
|
|
|
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
|
// Initialize default user repository and services (Postgres from cfg)
|
|
userRepo, userService, err := initializeUserServices(cfg)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to initialize user services, user functionality will be disabled")
|
|
}
|
|
return NewServerWithUserRepo(cfg, readyCtx, userRepo, userService)
|
|
}
|
|
|
|
// NewServerWithUserRepo builds a Server with caller-provided userRepo + userService.
|
|
// Used by BDD test infra to inject a per-scenario repository (e.g., one connected
|
|
// to an isolated PostgreSQL schema). Pass nil for both to disable user functionality.
|
|
//
|
|
// The validator + cache services are still built from cfg internally; they don't
|
|
// need per-scenario isolation today.
|
|
func NewServerWithUserRepo(cfg *config.Config, readyCtx context.Context, userRepo user.UserRepository, userService user.UserService) *Server {
|
|
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")
|
|
}
|
|
|
|
var cacheService cache.Service
|
|
if cfg.GetCacheEnabled() {
|
|
cacheService = cache.NewInMemoryService(
|
|
time.Duration(cfg.GetCacheDefaultTTLSeconds())*time.Second,
|
|
time.Duration(cfg.GetCacheCleanupIntervalSeconds())*time.Second,
|
|
)
|
|
log.Trace().Msg("Cache service initialized")
|
|
} else {
|
|
log.Trace().Msg("Cache service disabled")
|
|
}
|
|
|
|
s := &Server{
|
|
router: chi.NewRouter(),
|
|
readyCtx: readyCtx,
|
|
withOTEL: cfg.GetTelemetryEnabled(),
|
|
config: cfg,
|
|
validator: validator,
|
|
userRepo: userRepo,
|
|
userService: userService,
|
|
cacheService: cacheService,
|
|
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
|
|
}
|
|
|
|
// GetCacheService returns the cache service for test cleanup
|
|
// This allows test suites to flush cache between tests
|
|
func (s *Server) GetCacheService() cache.Service {
|
|
return s.cacheService
|
|
}
|
|
|
|
// 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.
|
|
// GetTTL is a method value — it captures cfg, so when WatchAndApply
|
|
// re-unmarshals into the same Config struct on file changes, every
|
|
// subsequent token generation reads the new TTL (ADR-0023 Phase 2).
|
|
// ExpirationTime is kept as a static fallback for tests that build
|
|
// JWTConfig manually without a Config.
|
|
jwtConfig := user.JWTConfig{
|
|
Secret: cfg.GetJWTSecret(),
|
|
ExpirationTime: 24 * time.Hour,
|
|
GetTTL: cfg.GetJWTTTL,
|
|
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(chimiddleware.RequestLogger(&chimiddleware.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)
|
|
|
|
// Info endpoint - composite aggregator
|
|
s.router.Get("/api/info", s.handleInfo)
|
|
|
|
// API routes
|
|
s.router.Route("/api/v1", func(r chi.Router) {
|
|
r.Use(s.getAllMiddlewares()...)
|
|
s.registerApiV1Routes(r)
|
|
})
|
|
|
|
// Admin routes
|
|
s.router.Route("/api/admin", func(r chi.Router) {
|
|
r.Use(s.getAllMiddlewares()...)
|
|
r.Post("/cache/flush", s.handleAdminCacheFlush)
|
|
})
|
|
|
|
// 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) {
|
|
// Create rate limit middleware
|
|
rateLimitMiddleware := middleware.NewRateLimiter(middleware.RateLimitConfig{
|
|
Enabled: s.config.GetRateLimitEnabled(),
|
|
RequestsPerMinute: s.config.GetRateLimitRequestsPerMinute(),
|
|
BurstSize: s.config.GetRateLimitBurstSize(),
|
|
})
|
|
|
|
// Create auth middleware if available
|
|
var authMiddleware *AuthMiddleware
|
|
if s.userService != nil {
|
|
authMiddleware = NewAuthMiddleware(s.userService)
|
|
}
|
|
|
|
r.Route("/greet", func(r chi.Router) {
|
|
// Add rate limiting middleware for greet endpoint
|
|
r.Use(rateLimitMiddleware.Middleware)
|
|
// Add optional authentication middleware
|
|
if authMiddleware != nil {
|
|
r.Use(authMiddleware.Middleware)
|
|
}
|
|
r.Get("/", s.handleGreetQuery)
|
|
r.Get("/{name}", s.handleGreetPath)
|
|
})
|
|
|
|
// 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{
|
|
chimiddleware.StripSlashes,
|
|
chimiddleware.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
|
|
}
|
|
|
|
// Check cache if enabled
|
|
cacheKey := "version:" + format
|
|
if s.cacheService != nil {
|
|
if cached, ok := s.cacheService.Get(cacheKey); ok {
|
|
log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for version")
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
if format == "json" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
}
|
|
w.Write([]byte(cached.(string)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Build response
|
|
var response string
|
|
switch format {
|
|
case "plain":
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
response = version.Short()
|
|
case "full":
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
response = version.Full()
|
|
case "json":
|
|
w.Header().Set("Content-Type", "application/json")
|
|
response = fmt.Sprintf(`{
|
|
"version": "%s",
|
|
"commit": "%s",
|
|
"built": "%s",
|
|
"go": "%s"
|
|
}`, version.Version, version.Commit, version.Date, version.GoVersion)
|
|
default:
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
response = version.Short()
|
|
}
|
|
|
|
// Cache the response for 60 seconds if cache is enabled
|
|
if s.cacheService != nil {
|
|
s.cacheService.Set(cacheKey, response, 60*time.Second)
|
|
log.Trace().Str("cache_key", cacheKey).Msg("Cached version response")
|
|
}
|
|
|
|
w.Write([]byte(response))
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// InfoResponse represents the JSON response for /api/info
|
|
type InfoResponse struct {
|
|
Version string `json:"version"`
|
|
CommitShort string `json:"commit_short"`
|
|
BuildDate string `json:"build_date"`
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
CacheEnabled bool `json:"cache_enabled"`
|
|
HealthzStatus string `json:"healthz_status"`
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// handleInfo godoc
|
|
//
|
|
// @Summary Get composite info
|
|
// @Description Returns aggregated version, build, uptime, cache, and health info
|
|
// @Tags System/Info
|
|
// @Produce json
|
|
// @Success 200 {object} InfoResponse
|
|
// @Router /info [get]
|
|
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|
log.Trace().Msg("Info endpoint requested")
|
|
|
|
// Build commit_short from version.Commit (first 8 chars if available)
|
|
commitShort := version.Commit
|
|
if len(commitShort) > 8 {
|
|
commitShort = commitShort[:8]
|
|
}
|
|
|
|
// Build response
|
|
resp := InfoResponse{
|
|
Version: version.Version,
|
|
CommitShort: commitShort,
|
|
BuildDate: version.Date,
|
|
UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
|
|
CacheEnabled: s.cacheService != nil,
|
|
HealthzStatus: "healthy",
|
|
}
|
|
|
|
// Cache key
|
|
cacheKey := "info:json"
|
|
|
|
// Check cache if enabled
|
|
if s.cacheService != nil {
|
|
if cached, ok := s.cacheService.Get(cacheKey); ok {
|
|
log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for info")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("X-Cache", "HIT")
|
|
w.Write([]byte(cached.(string)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Marshal response
|
|
data, err := json.Marshal(resp)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Cache the response
|
|
if s.cacheService != nil {
|
|
s.cacheService.Set(cacheKey, string(data),
|
|
time.Duration(s.config.GetCacheDefaultTTLSeconds())*time.Second)
|
|
w.Header().Set("X-Cache", "MISS")
|
|
log.Trace().Str("cache_key", cacheKey).Msg("Cached info response")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(data)
|
|
}
|
|
|
|
// handleGreetQuery godoc
|
|
//
|
|
// @Summary Get greeting with cache
|
|
// @Description Returns greeting for name from query param with caching
|
|
// @Tags API/v1/Greeting
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name query string false "Name to greet"
|
|
// @Success 200 {object} map[string]string "Greeting message"
|
|
// @Failure 400 {object} map[string]string "Invalid request"
|
|
// @Router /v1/greet [get]
|
|
func (s *Server) handleGreetQuery(w http.ResponseWriter, r *http.Request) {
|
|
name := r.URL.Query().Get("name")
|
|
cacheKey := "greet:v1:" + name
|
|
|
|
// Check cache if enabled
|
|
if s.cacheService != nil {
|
|
if cached, ok := s.cacheService.Get(cacheKey); ok {
|
|
log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for greet")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("X-Cache", "HIT")
|
|
w.Write([]byte(cached.(string)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Compute response
|
|
greetService := greet.NewService()
|
|
message := greetService.Greet(r.Context(), name)
|
|
response, err := json.Marshal(map[string]string{"message": message})
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Cache the response for 60 seconds if cache is enabled
|
|
if s.cacheService != nil {
|
|
s.cacheService.Set(cacheKey, string(response), 60*time.Second)
|
|
w.Header().Set("X-Cache", "MISS")
|
|
log.Trace().Str("cache_key", cacheKey).Msg("Cached greet response")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(response)
|
|
}
|
|
|
|
// handleGreetPath godoc
|
|
//
|
|
// @Summary Get personalized greeting with cache
|
|
// @Description Returns greeting for name from path param with caching
|
|
// @Tags API/v1/Greeting
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param name path string true "Name to greet"
|
|
// @Success 200 {object} map[string]string "Greeting message"
|
|
// @Failure 400 {object} map[string]string "Invalid request"
|
|
// @Router /v1/greet/{name} [get]
|
|
func (s *Server) handleGreetPath(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
cacheKey := "greet:v1:" + name
|
|
|
|
// Check cache if enabled
|
|
if s.cacheService != nil {
|
|
if cached, ok := s.cacheService.Get(cacheKey); ok {
|
|
log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for greet")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("X-Cache", "HIT")
|
|
w.Write([]byte(cached.(string)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Compute response
|
|
greetService := greet.NewService()
|
|
message := greetService.Greet(r.Context(), name)
|
|
response, err := json.Marshal(map[string]string{"message": message})
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Cache the response for 60 seconds if cache is enabled
|
|
if s.cacheService != nil {
|
|
s.cacheService.Set(cacheKey, string(response), 60*time.Second)
|
|
w.Header().Set("X-Cache", "MISS")
|
|
log.Trace().Str("cache_key", cacheKey).Msg("Cached greet response")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(response)
|
|
}
|
|
|
|
// handleAdminCacheFlush godoc
|
|
//
|
|
// @Summary Flush cache
|
|
// @Description Flushes the entire cache, requires admin authentication
|
|
// @Tags API/Admin
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param X-Admin-Password header string true "Admin master password"
|
|
// @Success 200 {object} map[string]interface{} "Cache flushed successfully"
|
|
// @Failure 401 {object} map[string]string "Unauthorized"
|
|
// @Failure 503 {object} map[string]string "Cache disabled"
|
|
// @Router /admin/cache/flush [post]
|
|
func (s *Server) handleAdminCacheFlush(w http.ResponseWriter, r *http.Request) {
|
|
if s.cacheService == nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "cache_disabled"})
|
|
return
|
|
}
|
|
|
|
// Admin auth - check X-Admin-Password header
|
|
masterPassword := r.Header.Get("X-Admin-Password")
|
|
if masterPassword == "" {
|
|
http.Error(w, `{"error":"unauthorized","message":"Admin password required"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
_, err := s.userService.AdminAuthenticate(r.Context(), masterPassword)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"unauthorized","message":"Invalid admin password"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
itemCount := s.cacheService.ItemCount()
|
|
s.cacheService.Flush()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"flushed": true,
|
|
"items_flushed": itemCount,
|
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
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()
|
|
|
|
// Start the JWT secret cleanup loop (ADR-0021). The loop runs until rootCtx
|
|
// is cancelled (graceful shutdown), removing non-primary secrets whose
|
|
// ExpiresAt is in the past.
|
|
if s.userService != nil {
|
|
s.userService.StartJWTSecretCleanupLoop(rootCtx, s.config.GetJWTSecretCleanupInterval())
|
|
}
|
|
|
|
// Start config hot-reload watcher (ADR-0023 Phase 1: logging.level only).
|
|
// Stops automatically on rootCtx cancellation.
|
|
s.config.WatchAndApply(rootCtx)
|
|
|
|
// 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
|
|
}
|