✨ feat(server): add /api/healthz endpoint with rich health info
Adds a Kubernetes-style healthz endpoint returning status, version, uptime_seconds and timestamp. Non-breaking — /api/health is preserved. - New route: GET /api/healthz - New handler: handleHealthz with HealthzResponse struct - New unit test: pkg/server/healthz_test.go (passes locally) - New BDD scenario: features/health/health.feature - BDD steps: pkg/bdd/steps/health_steps.go, common_steps.go Note: BDD tests require Postgres and will be validated by CI. 🤖 Co-Authored-By: Mistral Vibe (devstral-2 / mistral-medium-3.5) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,4 +7,12 @@ Feature: Health Endpoint
|
|||||||
Scenario: Health check returns healthy status
|
Scenario: Health check returns healthy status
|
||||||
Given the server is running
|
Given the server is running
|
||||||
When I request the health endpoint
|
When I request the health endpoint
|
||||||
Then the response should be "{\"status\":\"healthy\"}"
|
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"
|
||||||
@@ -63,3 +63,39 @@ func (s *CommonSteps) theStatusCodeShouldBe(expectedStatus int) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ func (s *HealthSteps) iRequestTheHealthEndpoint() error {
|
|||||||
return s.client.Request("GET", "/api/health", nil)
|
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 {
|
func (s *HealthSteps) theServerIsRunning() error {
|
||||||
// Actually verify the server is running by checking the readiness endpoint
|
// Actually verify the server is running by checking the readiness endpoint
|
||||||
return s.client.Request("GET", "/api/ready", nil)
|
return s.client.Request("GET", "/api/ready", nil)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s
|
|||||||
|
|
||||||
// Health steps
|
// Health steps
|
||||||
ctx.Step(`^I request the health endpoint$`, sc.healthSteps.iRequestTheHealthEndpoint)
|
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)
|
ctx.Step(`^the server is running$`, sc.healthSteps.theServerIsRunning)
|
||||||
|
|
||||||
// Auth steps
|
// 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 be "{\\"([^"]*)":\\"([^"]*)"}"$`, sc.commonSteps.theResponseShouldBe)
|
||||||
ctx.Step(`^the response should contain error "([^"]*)"$`, sc.commonSteps.theResponseShouldContainError)
|
ctx.Step(`^the response should contain error "([^"]*)"$`, sc.commonSteps.theResponseShouldContainError)
|
||||||
ctx.Step(`^the status code should be (\d+)$`, sc.commonSteps.theStatusCodeShouldBe)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
43
pkg/server/healthz_test.go
Normal file
43
pkg/server/healthz_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ type Server struct {
|
|||||||
validator *validation.Validator
|
validator *validation.Validator
|
||||||
userRepo user.UserRepository
|
userRepo user.UserRepository
|
||||||
userService user.UserService
|
userService user.UserService
|
||||||
|
startedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
||||||
@@ -89,6 +90,7 @@ func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
|||||||
validator: validator,
|
validator: validator,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
|
startedAt: time.Now(),
|
||||||
}
|
}
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
return s
|
return s
|
||||||
@@ -137,6 +139,9 @@ func (s *Server) setupRoutes() {
|
|||||||
// Version endpoint at root level
|
// Version endpoint at root level
|
||||||
s.router.Get("/api/version", s.handleVersion)
|
s.router.Get("/api/version", s.handleVersion)
|
||||||
|
|
||||||
|
// Kubernetes-style health endpoint at root level
|
||||||
|
s.router.Get("/api/healthz", s.handleHealthz)
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
s.router.Route("/api/v1", func(r chi.Router) {
|
s.router.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Use(s.getAllMiddlewares()...)
|
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 {
|
func (s *Server) Router() http.Handler {
|
||||||
return s.router
|
return s.router
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user