Add new endpoint that returns server start time and uptime duration in seconds.
The endpoint follows the same pattern as handleHealth and handleInfo handlers,
using the existing startedAt field from the Server struct.
Response format:
{
"start_time": "2026-05-05T19:30:00Z",
"uptime_seconds": 1234
}
Includes unit tests with httptest.NewRecorder() to verify JSON response
structure and content type headers.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
879 lines
28 KiB
Go
879 lines
28 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"
|
|
"runtime"
|
|
"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/email"
|
|
"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 ALWAYS (ADR-0023 Phase 4 hot-reload). The
|
|
// v2EnabledGate middleware checks the live config on every request
|
|
// and returns 404 when api.v2_enabled is false. This lets the flag
|
|
// be flipped via config hot-reload without a router rebuild.
|
|
s.router.Route("/api/v2", func(r chi.Router) {
|
|
r.Use(s.getAllMiddlewares()...)
|
|
r.Use(s.v2EnabledGate)
|
|
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)
|
|
})
|
|
|
|
// Uptime endpoint
|
|
r.Get("/uptime", s.handleUptime)
|
|
|
|
// 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)
|
|
// Magic-link routes (ADR-0028 Phase A). Mounted only when the
|
|
// userRepo also implements MagicLinkRepository (PostgresRepository does).
|
|
if mlRepo, ok := s.userRepo.(user.MagicLinkRepository); ok {
|
|
emailCfg := s.config.GetEmailConfig()
|
|
sender := email.NewSMTPSender(email.SMTPConfig{
|
|
Host: emailCfg.SMTPHost,
|
|
Port: emailCfg.SMTPPort,
|
|
Username: emailCfg.SMTPUsername,
|
|
Password: emailCfg.SMTPPassword,
|
|
UseTLS: emailCfg.SMTPUseTLS,
|
|
Timeout: emailCfg.Timeout,
|
|
})
|
|
mlHandler := userapi.NewMagicLinkHandler(
|
|
mlRepo,
|
|
s.userService,
|
|
s.userRepo,
|
|
sender,
|
|
s.config.GetMagicLinkConfig(),
|
|
emailCfg.From,
|
|
s.validator,
|
|
)
|
|
mlHandler.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)
|
|
})
|
|
}
|
|
|
|
// v2EnabledGate is the middleware that gates the /api/v2/* subtree on the
|
|
// live api.v2_enabled config value (ADR-0023 Phase 4 hot-reload). When
|
|
// disabled, returns 404 with the same body shape as a missing route would
|
|
// emit, so clients see "v2 doesn't exist" rather than "v2 is forbidden".
|
|
//
|
|
// Flipping the config at runtime via Config.WatchAndApply takes effect on
|
|
// the next request — no router rebuild, no restart.
|
|
func (s *Server) v2EnabledGate(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !s.config.GetV2Enabled() {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":"not_found","message":"v2 API is currently disabled"}`))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, 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"`
|
|
GoVersion string `json:"go_version"`
|
|
}
|
|
|
|
// 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",
|
|
GoVersion: runtime.Version(),
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// UptimeResponse represents the JSON response for /api/v1/uptime
|
|
type UptimeResponse struct {
|
|
StartTime string `json:"start_time"`
|
|
UptimeSeconds int `json:"uptime_seconds"`
|
|
}
|
|
|
|
// handleUptime godoc
|
|
//
|
|
// @Summary Get server uptime
|
|
// @Description Returns server start time and uptime duration
|
|
// @Tags System/Info
|
|
// @Produce json
|
|
// @Success 200 {object} UptimeResponse
|
|
// @Router /v1/uptime [get]
|
|
func (s *Server) handleUptime(w http.ResponseWriter, r *http.Request) {
|
|
log.Trace().Msg("Uptime check requested")
|
|
resp := UptimeResponse{
|
|
StartTime: s.startedAt.Format(time.RFC3339),
|
|
UptimeSeconds: int(time.Since(s.startedAt).Seconds()),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// 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
|
|
var telemetrySetup *telemetry.Setup
|
|
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
|
|
telemetrySetup = nil
|
|
} 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())
|
|
}
|
|
|
|
// Wire the sampler hot-reload callback (ADR-0023 Phase 3, sub-phase 3.3).
|
|
// telemetrySetup is non-nil only when telemetry was successfully initialized
|
|
// at startup — hot-reloading telemetry-on is out of scope (see ADR-0023).
|
|
// The callback updates the SamplerType/Ratio on the captured Setup, then
|
|
// rebuilds the global tracer provider via ReconfigureTracerProvider.
|
|
if telemetrySetup != nil {
|
|
s.config.SetSamplerReconfigureCallback(func(ctx context.Context, samplerType string, samplerRatio float64) error {
|
|
telemetrySetup.SamplerType = samplerType
|
|
telemetrySetup.SamplerRatio = samplerRatio
|
|
newTP, rerr := telemetrySetup.ReconfigureTracerProvider(ctx, s.tracerProvider)
|
|
if rerr != nil {
|
|
return rerr
|
|
}
|
|
if newTP != nil {
|
|
s.tracerProvider = newTP
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Start config hot-reload watcher (ADR-0023 Phase 1+2+3).
|
|
// 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
|
|
}
|