From 045823ec8e08dffeb8527e1dfc4ee4f53f5c778a Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 3 May 2026 12:25:54 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(server):=20add=20/api/healthz?= =?UTF-8?q?=20endpoint=20with=20rich=20health=20info=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Kubernetes-style /api/healthz endpoint with status/version/uptime_seconds/timestamp. Non-breaking — /api/health preserved. Includes unit test (passes locally) and BDD scenario (validated by CI). Généré ~95% en autonomie par Mistral Vibe via workspace ICM ~/Work/Vibe/workspaces/healthz-feature/. Co-authored-by: Gabriel Radureau Co-committed-by: Gabriel Radureau --- features/health/health.feature | 10 +++++++- pkg/bdd/steps/common_steps.go | 36 ++++++++++++++++++++++++++++ pkg/bdd/steps/health_steps.go | 4 ++++ pkg/bdd/steps/steps.go | 3 +++ pkg/server/healthz_test.go | 43 ++++++++++++++++++++++++++++++++++ pkg/server/server.go | 33 ++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 pkg/server/healthz_test.go diff --git a/features/health/health.feature b/features/health/health.feature index c897150..4985345 100644 --- a/features/health/health.feature +++ b/features/health/health.feature @@ -7,4 +7,12 @@ Feature: Health Endpoint Scenario: Health check returns healthy status Given the server is running When I request the health endpoint - Then the response should be "{\"status\":\"healthy\"}" \ No newline at end of file + Then the response should be "{\"status\":\"healthy\"}" + + @basic @critical + Scenario: Healthz endpoint returns rich health info + Given the server is running + When I request the healthz endpoint + Then the response status code should be 200 + And the response should be JSON with fields "status, version, uptime_seconds, timestamp" + And the "status" field should equal "healthy" \ No newline at end of file diff --git a/pkg/bdd/steps/common_steps.go b/pkg/bdd/steps/common_steps.go index f9b96d7..7c4a4cc 100644 --- a/pkg/bdd/steps/common_steps.go +++ b/pkg/bdd/steps/common_steps.go @@ -63,3 +63,39 @@ func (s *CommonSteps) theStatusCodeShouldBe(expectedStatus int) error { } return nil } + +// JSON field validation +func (s *CommonSteps) theResponseShouldBeJSONWithFields(fields string) error { + // Parse the fields comma-separated list + fieldList := strings.Split(fields, ", ") + for _, field := range fieldList { + field = strings.TrimSpace(field) + if !s.responseContainsJSONField(field) { + return fmt.Errorf("response does not contain field %q", field) + } + } + return nil +} + +func (s *CommonSteps) responseContainsJSONField(field string) bool { + body := string(s.client.GetLastBody()) + // Simple check - look for "field":" in the JSON + // This works for simple fields, may need enhancement for nested objects + searchString := `"` + field + `":` + return strings.Contains(body, searchString) +} + +func (s *CommonSteps) theFieldShouldEqual(field, expectedValue string) error { + body := string(s.client.GetLastBody()) + // Look for the field and extract its value + // Simple implementation: look for "field":"value" pattern + searchPattern := `"` + field + `":"` + expectedValue + `"` + if !strings.Contains(body, searchPattern) { + // Also try without quotes (for numbers) + searchPatternNum := `"` + field + `":` + expectedValue + if !strings.Contains(body, searchPatternNum) { + return fmt.Errorf("field %q does not equal %q in response: %s", field, expectedValue, body) + } + } + return nil +} diff --git a/pkg/bdd/steps/health_steps.go b/pkg/bdd/steps/health_steps.go index e74a63c..3930009 100644 --- a/pkg/bdd/steps/health_steps.go +++ b/pkg/bdd/steps/health_steps.go @@ -24,6 +24,10 @@ func (s *HealthSteps) iRequestTheHealthEndpoint() error { return s.client.Request("GET", "/api/health", nil) } +func (s *HealthSteps) iRequestTheHealthzEndpoint() error { + return s.client.Request("GET", "/api/healthz", nil) +} + func (s *HealthSteps) theServerIsRunning() error { // Actually verify the server is running by checking the readiness endpoint return s.client.Request("GET", "/api/ready", nil) diff --git a/pkg/bdd/steps/steps.go b/pkg/bdd/steps/steps.go index 2c59b21..d152684 100644 --- a/pkg/bdd/steps/steps.go +++ b/pkg/bdd/steps/steps.go @@ -83,6 +83,7 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s // Health steps ctx.Step(`^I request the health endpoint$`, sc.healthSteps.iRequestTheHealthEndpoint) + ctx.Step(`^I request the healthz endpoint$`, sc.healthSteps.iRequestTheHealthzEndpoint) ctx.Step(`^the server is running$`, sc.healthSteps.theServerIsRunning) // Auth steps @@ -297,4 +298,6 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s ctx.Step(`^the response should be "{\\"([^"]*)":\\"([^"]*)"}"$`, sc.commonSteps.theResponseShouldBe) ctx.Step(`^the response should contain error "([^"]*)"$`, sc.commonSteps.theResponseShouldContainError) ctx.Step(`^the status code should be (\d+)$`, sc.commonSteps.theStatusCodeShouldBe) + ctx.Step(`^the response should be JSON with fields "([^"]*)"$`, sc.commonSteps.theResponseShouldBeJSONWithFields) + ctx.Step(`^the "([^"]*)" field should equal "([^"]*)"$`, sc.commonSteps.theFieldShouldEqual) } diff --git a/pkg/server/healthz_test.go b/pkg/server/healthz_test.go new file mode 100644 index 0000000..cb6c6e2 --- /dev/null +++ b/pkg/server/healthz_test.go @@ -0,0 +1,43 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "dance-lessons-coach/pkg/config" + + "github.com/stretchr/testify/assert" +) + +func TestHandleHealthz(t *testing.T) { + // Setup + cfg := &config.Config{} + s := NewServer(cfg, context.Background()) + + // Create request + req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil) + w := httptest.NewRecorder() + + // Call handler + s.handleHealthz(w, req) + + // Check status code + assert.Equal(t, http.StatusOK, w.Code) + + // Check content type + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + // Decode response + var resp HealthzResponse + err := json.NewDecoder(w.Body).Decode(&resp) + assert.NoError(t, err) + + // Assert fields + assert.Equal(t, "healthy", resp.Status) + assert.NotEmpty(t, resp.Version) + assert.GreaterOrEqual(t, resp.UptimeSeconds, int64(0)) + assert.NotZero(t, resp.Timestamp) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 9c20539..c01c88e 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -64,6 +64,7 @@ type Server struct { validator *validation.Validator userRepo user.UserRepository userService user.UserService + startedAt time.Time } func NewServer(cfg *config.Config, readyCtx context.Context) *Server { @@ -89,6 +90,7 @@ func NewServer(cfg *config.Config, readyCtx context.Context) *Server { validator: validator, userRepo: userRepo, userService: userService, + startedAt: time.Now(), } s.setupRoutes() return s @@ -137,6 +139,9 @@ func (s *Server) setupRoutes() { // 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) + // API routes s.router.Route("/api/v1", func(r chi.Router) { r.Use(s.getAllMiddlewares()...) @@ -358,6 +363,34 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { } } +// 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"` +} + +// 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) +} + func (s *Server) Router() http.Handler { return s.router }