diff --git a/README.md b/README.md index df3be22..64efb65 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ go test -tags=integration ./... - `GET /health/live` reports process liveness without checking PostgreSQL. - `GET /health/ready` reports database readiness with a bounded PostgreSQL ping. -- `GET /metrics` exposes Prometheus metrics only when `ENABLE_METRICS=true`. +- `GET /metrics` exposes Prometheus metrics only when `ENABLE_METRICS=true`; it is an operational endpoint and is intentionally excluded from Swagger. - Security audit events are emitted as structured JSON logs with request IDs and safe categorical context. Metrics labels use route templates and avoid user-controlled values. Restrict `/metrics` to trusted monitoring systems in production. Grafana, Loki, and tracing infrastructure are intentionally not bundled. diff --git a/docs/docs.go b/docs/docs.go index 6031b74..c01c946 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -762,6 +762,54 @@ const docTemplate = `{ } } } + }, + "/health/live": { + "get": { + "description": "Returns liveness without checking external dependencies.", + "produces": [ + "application/json" + ], + "tags": [ + "operations" + ], + "summary": "Check process liveness", + "operationId": "healthLive", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + } + } + } + } + }, + "/health/ready": { + "get": { + "description": "Checks PostgreSQL connectivity with a bounded timeout.", + "produces": [ + "application/json" + ], + "tags": [ + "operations" + ], + "summary": "Check service readiness", + "operationId": "healthReady", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + } + } + } + } } }, "definitions": { @@ -874,6 +922,15 @@ const docTemplate = `{ } } }, + "internal_adapter_inbound_http.HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + }, "internal_adapter_inbound_http.MessageResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 38e36d1..4b0764a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -760,6 +760,54 @@ } } } + }, + "/health/live": { + "get": { + "description": "Returns liveness without checking external dependencies.", + "produces": [ + "application/json" + ], + "tags": [ + "operations" + ], + "summary": "Check process liveness", + "operationId": "healthLive", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + } + } + } + } + }, + "/health/ready": { + "get": { + "description": "Checks PostgreSQL connectivity with a bounded timeout.", + "produces": [ + "application/json" + ], + "tags": [ + "operations" + ], + "summary": "Check service readiness", + "operationId": "healthReady", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/internal_adapter_inbound_http.HealthResponse" + } + } + } + } } }, "definitions": { @@ -872,6 +920,15 @@ } } }, + "internal_adapter_inbound_http.HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + }, "internal_adapter_inbound_http.MessageResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c8f5584..dbe3933 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -74,6 +74,12 @@ definitions: example: invalid input type: string type: object + internal_adapter_inbound_http.HealthResponse: + properties: + status: + example: ok + type: string + type: object internal_adapter_inbound_http.MessageResponse: properties: message: @@ -591,6 +597,38 @@ paths: summary: Update current user tags: - users + /health/live: + get: + description: Returns liveness without checking external dependencies. + operationId: healthLive + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_adapter_inbound_http.HealthResponse' + summary: Check process liveness + tags: + - operations + /health/ready: + get: + description: Checks PostgreSQL connectivity with a bounded timeout. + operationId: healthReady + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_adapter_inbound_http.HealthResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/internal_adapter_inbound_http.HealthResponse' + summary: Check service readiness + tags: + - operations schemes: - http - https diff --git a/internal/adapter/inbound/http/health.go b/internal/adapter/inbound/http/health.go index 28043d5..812f222 100644 --- a/internal/adapter/inbound/http/health.go +++ b/internal/adapter/inbound/http/health.go @@ -22,10 +22,27 @@ func NewHealthHandler(db DBPinger) *HealthHandler { return &HealthHandler{db: db} } +// Live godoc +// @Summary Check process liveness +// @ID healthLive +// @Description Returns liveness without checking external dependencies. +// @Tags operations +// @Produce json +// @Success 200 {object} HealthResponse +// @Router /health/live [get] func (h *HealthHandler) Live(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } +// Ready godoc +// @Summary Check service readiness +// @ID healthReady +// @Description Checks PostgreSQL connectivity with a bounded timeout. +// @Tags operations +// @Produce json +// @Success 200 {object} HealthResponse +// @Failure 503 {object} HealthResponse +// @Router /health/ready [get] func (h *HealthHandler) Ready(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"}) diff --git a/internal/adapter/inbound/http/metrics.go b/internal/adapter/inbound/http/metrics.go new file mode 100644 index 0000000..69a984f --- /dev/null +++ b/internal/adapter/inbound/http/metrics.go @@ -0,0 +1,13 @@ +package http + +import ( + "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/gin-gonic/gin" +) + +func RegisterMetricsRoute(r *gin.Engine, metrics *platform.Metrics, enabled bool) { + if !enabled || metrics == nil { + return + } + r.GET("/metrics", gin.WrapH(metrics.Handler())) +} diff --git a/internal/adapter/inbound/http/metrics_test.go b/internal/adapter/inbound/http/metrics_test.go new file mode 100644 index 0000000..fc7c084 --- /dev/null +++ b/internal/adapter/inbound/http/metrics_test.go @@ -0,0 +1,84 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + inhttpmw "github.com/Jonathan0823/auth-go/internal/adapter/inbound/http/middleware" + "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/gin-gonic/gin" +) + +func TestMetricsMiddlewareUsesRouteTemplates(t *testing.T) { + gin.SetMode(gin.TestMode) + metrics := platform.NewMetrics(nil) + router := gin.New() + router.Use(inhttpmw.Metrics(metrics)) + router.GET("/users/:id", func(c *gin.Context) {}) + + for _, id := range []string{"1", "2"} { + request := httptest.NewRequest(http.MethodGet, "/users/"+id, nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + } + + families, err := metrics.Registry.Gather() + if err != nil { + t.Fatal(err) + } + var requestCount float64 + for _, family := range families { + if family.GetName() != "auth_go_http_requests_total" { + continue + } + for _, metric := range family.GetMetric() { + labels := make(map[string]string) + for _, label := range metric.GetLabel() { + labels[label.GetName()] = label.GetValue() + } + if labels["route"] != "/users/:id" { + t.Fatalf("route label = %q, want /users/:id", labels["route"]) + } + requestCount += metric.GetCounter().GetValue() + } + } + if requestCount != 2 { + t.Fatalf("request count = %v, want 2", requestCount) + } +} + +func TestMetricsRouteIsOptIn(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, enabled := range []bool{false, true} { + t.Run(map[bool]string{false: "disabled", true: "enabled"}[enabled], func(t *testing.T) { + metrics := platform.NewMetrics(nil) + router := gin.New() + if enabled { + router.Use(inhttpmw.Metrics(metrics)) + router.GET("/ping", func(c *gin.Context) {}) + } + RegisterMetricsRoute(router, metrics, enabled) + if enabled { + pingRequest := httptest.NewRequest(http.MethodGet, "/ping", nil) + pingResponse := httptest.NewRecorder() + router.ServeHTTP(pingResponse, pingRequest) + } + request := httptest.NewRequest(http.MethodGet, "/metrics", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + wantStatus := http.StatusNotFound + if enabled { + wantStatus = http.StatusOK + } + if response.Code != wantStatus { + t.Fatalf("status = %d, want %d", response.Code, wantStatus) + } + if enabled && !strings.Contains(response.Body.String(), "auth_go_http_requests_total") { + t.Fatal("metrics response does not contain HTTP request metric") + } + }) + } +} diff --git a/internal/adapter/inbound/http/middleware/metrics.go b/internal/adapter/inbound/http/middleware/metrics.go new file mode 100644 index 0000000..19819d9 --- /dev/null +++ b/internal/adapter/inbound/http/middleware/metrics.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "strconv" + "time" + + "github.com/Jonathan0823/auth-go/internal/platform" + "github.com/gin-gonic/gin" +) + +func Metrics(metrics *platform.Metrics) gin.HandlerFunc { + if metrics == nil { + return func(c *gin.Context) { c.Next() } + } + + return func(c *gin.Context) { + metrics.IncInFlight() + started := time.Now() + c.Next() + + route := c.FullPath() + if route == "" { + route = "/__unmatched__" + } + metrics.ObserveHTTPRequest( + c.Request.Method, + route, + strconv.Itoa(c.Writer.Status()), + time.Since(started), + ) + metrics.DecInFlight() + } +} diff --git a/internal/adapter/inbound/http/swagger_test.go b/internal/adapter/inbound/http/swagger_test.go index 8bd265b..4136ae6 100644 --- a/internal/adapter/inbound/http/swagger_test.go +++ b/internal/adapter/inbound/http/swagger_test.go @@ -56,6 +56,9 @@ func TestSwaggerDocumentsAllAPIRoutes(t *testing.T) { documented := make(map[string]bool) for path, operations := range document.Paths { + if !strings.HasPrefix(path, "/api/") { + continue + } for method := range operations { documented[strings.ToUpper(method)+" "+path] = true } @@ -79,6 +82,29 @@ func TestSwaggerDocumentsAllAPIRoutes(t *testing.T) { } } +func TestSwaggerDocumentsOperationalRoutes(t *testing.T) { + spec, err := os.ReadFile(swaggerSpecPath(t)) + if err != nil { + t.Fatal(err) + } + var document struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + } + if err := json.Unmarshal(spec, &document); err != nil { + t.Fatalf("parse Swagger document: %v", err) + } + + for _, key := range []string{"GET /health/live", "GET /health/ready"} { + parts := strings.SplitN(key, " ", 2) + if _, ok := document.Paths[parts[1]][strings.ToLower(parts[0])]; !ok { + t.Errorf("operational route %s is not documented", key) + } + } + if _, ok := document.Paths["/metrics"]; ok { + t.Fatal("Prometheus metrics endpoint should not be included in Swagger") + } +} + func TestRegisterSwaggerRoutes(t *testing.T) { tests := []struct { name string diff --git a/internal/adapter/inbound/http/swagger_types.go b/internal/adapter/inbound/http/swagger_types.go index 172fa38..c23fba8 100644 --- a/internal/adapter/inbound/http/swagger_types.go +++ b/internal/adapter/inbound/http/swagger_types.go @@ -19,3 +19,7 @@ type UsersResponseEnvelope struct { type ErrorResponse struct { Error string `json:"error" example:"invalid input"` } + +type HealthResponse struct { + Status string `json:"status" example:"ok"` +} diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index a10e67f..a65e973 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -39,7 +39,7 @@ func Run(cfg platform.Config) { r.Use(inhttpmw.RequestID()) if cfg.EnableMetrics { - r.Use(metrics.Middleware()) + r.Use(inhttpmw.Metrics(metrics)) } r.Use(inhttpmw.RequestLogger(logger)) @@ -48,7 +48,7 @@ func Run(cfg platform.Config) { inhttp.RegisterRoutes(r, handler, logger) inhttp.RegisterSwaggerRoutes(r, cfg.EnableSwagger, cfg.Environment) inhttp.RegisterHealthRoutes(r, pool) - platform.RegisterMetricsRoute(r, metrics, cfg.EnableMetrics) + inhttp.RegisterMetricsRoute(r, metrics, cfg.EnableMetrics) platform.InitServer(r, cfg) } diff --git a/internal/platform/metrics.go b/internal/platform/metrics.go index 49b3e30..641ad49 100644 --- a/internal/platform/metrics.go +++ b/internal/platform/metrics.go @@ -2,9 +2,8 @@ package platform import ( "net/http" - "strconv" + "time" - "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -54,29 +53,21 @@ func NewMetrics(pool *pgxpool.Pool) *Metrics { return metrics } -func (m *Metrics) Middleware() gin.HandlerFunc { - return func(c *gin.Context) { - m.httpInFlight.Inc() - start := prometheus.NewTimer(prometheus.ObserverFunc(func(duration float64) { - method, route, status := requestLabels(c) - m.httpRequests.WithLabelValues(method, route, status).Inc() - m.httpDuration.WithLabelValues(method, route, status).Observe(duration) - })) - c.Next() - start.ObserveDuration() - m.httpInFlight.Dec() - } -} - func (m *Metrics) Handler() http.Handler { return promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{}) } -func RegisterMetricsRoute(r *gin.Engine, metrics *Metrics, enabled bool) { - if !enabled || metrics == nil { - return - } - r.GET("/metrics", gin.WrapH(metrics.Handler())) +func (m *Metrics) IncInFlight() { + m.httpInFlight.Inc() +} + +func (m *Metrics) DecInFlight() { + m.httpInFlight.Dec() +} + +func (m *Metrics) ObserveHTTPRequest(method, route, status string, duration time.Duration) { + m.httpRequests.WithLabelValues(method, route, status).Inc() + m.httpDuration.WithLabelValues(method, route, status).Observe(duration.Seconds()) } func (m *Metrics) RecordSecurityEvent(event, outcome, reason, provider string) { @@ -88,14 +79,6 @@ func (m *Metrics) RecordSecurityEvent(event, outcome, reason, provider string) { ).Inc() } -func requestLabels(c *gin.Context) (string, string, string) { - route := c.FullPath() - if route == "" { - route = "/__unmatched__" - } - return c.Request.Method, route, strconv.Itoa(c.Writer.Status()) -} - func normalizeEvent(event string) string { switch event { case EventUserRegister, EventAuthLogin, EventAuthLogout, EventAuthRefresh, diff --git a/internal/platform/metrics_test.go b/internal/platform/metrics_test.go index 91e995e..e65c0cd 100644 --- a/internal/platform/metrics_test.go +++ b/internal/platform/metrics_test.go @@ -5,90 +5,14 @@ import ( "context" "encoding/json" "log/slog" - "net/http" - "net/http/httptest" "strings" "testing" - - "github.com/gin-gonic/gin" ) func newTestLogger(output *bytes.Buffer) *slog.Logger { return slog.New(slog.NewJSONHandler(output, nil)) } -func TestMetricsMiddlewareUsesRouteTemplates(t *testing.T) { - gin.SetMode(gin.TestMode) - metrics := NewMetrics(nil) - router := gin.New() - router.Use(metrics.Middleware()) - router.GET("/users/:id", func(c *gin.Context) {}) - - for _, id := range []string{"1", "2"} { - request := httptest.NewRequest(http.MethodGet, "/users/"+id, nil) - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - } - - families, err := metrics.Registry.Gather() - if err != nil { - t.Fatal(err) - } - var requestCount float64 - for _, family := range families { - if family.GetName() != "auth_go_http_requests_total" { - continue - } - for _, metric := range family.GetMetric() { - labels := make(map[string]string) - for _, label := range metric.GetLabel() { - labels[label.GetName()] = label.GetValue() - } - if labels["route"] != "/users/:id" { - t.Fatalf("route label = %q, want /users/:id", labels["route"]) - } - requestCount += metric.GetCounter().GetValue() - } - } - if requestCount != 2 { - t.Fatalf("request count = %v, want 2", requestCount) - } -} - -func TestMetricsRouteIsOptIn(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, enabled := range []bool{false, true} { - t.Run(map[bool]string{false: "disabled", true: "enabled"}[enabled], func(t *testing.T) { - metrics := NewMetrics(nil) - router := gin.New() - if enabled { - router.Use(metrics.Middleware()) - router.GET("/ping", func(c *gin.Context) {}) - } - RegisterMetricsRoute(router, metrics, enabled) - if enabled { - pingRequest := httptest.NewRequest(http.MethodGet, "/ping", nil) - pingResponse := httptest.NewRecorder() - router.ServeHTTP(pingResponse, pingRequest) - } - request := httptest.NewRequest(http.MethodGet, "/metrics", nil) - response := httptest.NewRecorder() - router.ServeHTTP(response, request) - - wantStatus := http.StatusNotFound - if enabled { - wantStatus = http.StatusOK - } - if response.Code != wantStatus { - t.Fatalf("status = %d, want %d", response.Code, wantStatus) - } - if enabled && !strings.Contains(response.Body.String(), "auth_go_http_requests_total") { - t.Fatal("metrics response does not contain HTTP request metric") - } - }) - } -} - func TestAuditLoggerUsesSafeFields(t *testing.T) { var output bytes.Buffer logger := NewAuditLogger(