diff --git a/apps/api/.env.example b/apps/api/.env.example index 6a217f4d..8f5a2369 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -4,6 +4,7 @@ DB_NAME= DB_HOST= DB_PORT= PORT= +BIND_ADDR= # interface to bind to (default: "" = all interfaces); set to 127.0.0.1 in production, behind a reverse proxy JWT_SECRET= # Logging diff --git a/apps/api/main.go b/apps/api/main.go index 82edda80..59f3d43f 100644 --- a/apps/api/main.go +++ b/apps/api/main.go @@ -6,9 +6,14 @@ import ( "apps/api/pkg/logger" "apps/api/presentation/restapi" "apps/api/utils" + "context" "fmt" "log/slog" "net/http" + "os" + "os/signal" + "syscall" + "time" "github.com/gorilla/mux" ) @@ -117,10 +122,45 @@ func main() { restapi.NewChecklistSessionRouter(checklistSessionHandler).AddRouter(router) restapi.NewStockCheckRouter(stockCheckHandler).AddRouter(router) - router.HandleFunc("/health-check", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("health check success")) - }) + startTime := time.Now() + router.HandleFunc("/health-check", restapi.NewHealthHandler(startTime).HealthCheck) + + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%s", env.BindAddr, env.Port), + Handler: router, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + serveErrCh := make(chan error, 1) + go func() { + rootLogger.Info("server listening", slog.String("addr", srv.Addr)) + serveErrCh <- srv.ListenAndServe() + }() - rootLogger.Info("server listening", slog.String("port", env.Port)) - http.ListenAndServe(fmt.Sprintf(":%s", env.Port), router) + select { + case err := <-serveErrCh: + if err != nil && err != http.ErrServerClosed { + rootLogger.Error("server failed", slog.String("error", err.Error())) + os.Exit(1) + } + case <-ctx.Done(): + stop() + rootLogger.Info("shutdown signal received, draining in-flight requests") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + rootLogger.Error("graceful shutdown failed", slog.String("error", err.Error())) + os.Exit(1) + } + + rootLogger.Info("server shut down cleanly") + } } diff --git a/apps/api/presentation/restapi/health_handler.go b/apps/api/presentation/restapi/health_handler.go new file mode 100644 index 00000000..4bbb44d8 --- /dev/null +++ b/apps/api/presentation/restapi/health_handler.go @@ -0,0 +1,34 @@ +package restapi + +import ( + "apps/api/pkg/buildinfo" + "encoding/json" + "net/http" + "time" +) + +type HealthResponse struct { + Status string `json:"status"` + Version string `json:"version"` + Commit string `json:"commit"` + UptimeSeconds int64 `json:"uptime_seconds"` +} + +type HealthHandler struct { + startTime time.Time +} + +func NewHealthHandler(startTime time.Time) *HealthHandler { + return &HealthHandler{startTime: startTime} +} + +func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(HealthResponse{ + Status: "ok", + Version: buildinfo.Version, + Commit: buildinfo.Version, + UptimeSeconds: int64(time.Since(h.startTime).Seconds()), + }) +} diff --git a/apps/api/presentation/restapi/health_handler_test.go b/apps/api/presentation/restapi/health_handler_test.go new file mode 100644 index 00000000..3191f7c8 --- /dev/null +++ b/apps/api/presentation/restapi/health_handler_test.go @@ -0,0 +1,33 @@ +package restapi_test + +import ( + "apps/api/pkg/buildinfo" + "apps/api/presentation/restapi" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestHealthHandler_HealthCheck(t *testing.T) { + startTime := time.Now().Add(-5 * time.Second) + handler := restapi.NewHealthHandler(startTime) + + req := httptest.NewRequest(http.MethodGet, "/health-check", nil) + w := httptest.NewRecorder() + handler.HealthCheck(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var body restapi.HealthResponse + err := json.Unmarshal(w.Body.Bytes(), &body) + assert.NoError(t, err) + assert.Equal(t, "ok", body.Status) + assert.Equal(t, buildinfo.Version, body.Version) + assert.Equal(t, buildinfo.Version, body.Commit) + assert.GreaterOrEqual(t, body.UptimeSeconds, int64(5)) +} diff --git a/apps/api/presentation/restapi/rental_handler_test.go b/apps/api/presentation/restapi/rental_handler_test.go index 97aeaed7..85f67cce 100644 --- a/apps/api/presentation/restapi/rental_handler_test.go +++ b/apps/api/presentation/restapi/rental_handler_test.go @@ -192,6 +192,7 @@ func TestRentalHandler_CheckoutRentals(t *testing.T) { PricingTiers: []domain.PricingTier{{UpToMinutes: 480, Price: 120000}}, }, nil) rentalRepo.EXPECT().CheckoutRental(gomock.Any(), int64(1)).Return(nil) + variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Product: domain.Product{Name: "Console"}}, nil) txRepo.EXPECT().CreateTransaction(gomock.Any(), gomock.Any()).Return(domain.Transaction{Id: 1}, nil) }, expectedStatus: http.StatusOK, diff --git a/apps/api/utils/env.go b/apps/api/utils/env.go index 096a2caa..a87388ab 100644 --- a/apps/api/utils/env.go +++ b/apps/api/utils/env.go @@ -17,6 +17,7 @@ type Env struct { DbHost string DbPort string Port string + BindAddr string JwtSecret string LogLevel string AppEnv string @@ -46,6 +47,7 @@ func GetEnv() Env { DbHost: os.Getenv("DB_HOST"), DbPort: os.Getenv("DB_PORT"), Port: os.Getenv("PORT"), + BindAddr: os.Getenv("BIND_ADDR"), JwtSecret: os.Getenv("JWT_SECRET"), LogLevel: logLevel, AppEnv: appEnv,