diff --git a/api.go b/api.go index 83f290e..3867987 100644 --- a/api.go +++ b/api.go @@ -10,6 +10,7 @@ import ( "net/http" "time" + "github.com/gin-contrib/expvar" "github.com/gin-contrib/pprof" "github.com/gin-gonic/gin" ) @@ -32,6 +33,7 @@ type Engine struct { swaggerDesc string swaggerVersion string pprofEnabled bool + expvarEnabled bool graphql *graphqlConfig } @@ -158,5 +160,10 @@ func (e *Engine) build() *gin.Engine { pprof.Register(r) } + // Mount expvar runtime metrics endpoint if enabled. + if e.expvarEnabled { + r.GET("/debug/vars", expvar.Handler()) + } + return r } diff --git a/expvar_test.go b/expvar_test.go new file mode 100644 index 0000000..0d2f86d --- /dev/null +++ b/expvar_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/go-api" +) + +// ── Expvar runtime metrics endpoint ───────────────────────────────── + +func TestWithExpvar_Good_EndpointReturnsJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithExpvar()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/vars") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for /debug/vars, got %d", resp.StatusCode) + } + + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Fatalf("expected application/json content type, got %q", ct) + } +} + +func TestWithExpvar_Good_ContainsMemstats(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithExpvar()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/vars") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + if !strings.Contains(string(body), "memstats") { + t.Fatal("expected response body to contain \"memstats\"") + } +} + +func TestWithExpvar_Good_ContainsCmdline(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithExpvar()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/vars") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + if !strings.Contains(string(body), "cmdline") { + t.Fatal("expected response body to contain \"cmdline\"") + } +} + +func TestWithExpvar_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithRequestID(), api.WithExpvar()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/vars") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for /debug/vars with middleware, got %d", resp.StatusCode) + } + + // Verify the request ID middleware is still active. + rid := resp.Header.Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header from WithRequestID middleware") + } +} + +func TestWithExpvar_Bad_NotMountedWithoutOption(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, _ := api.New() + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/debug/vars", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for /debug/vars without WithExpvar, got %d", w.Code) + } +} diff --git a/go.mod b/go.mod index 702ff89..cde97a6 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/coreos/go-oidc/v3 v3.17.0 github.com/gin-contrib/authz v1.0.6 github.com/gin-contrib/cors v1.7.6 + github.com/gin-contrib/expvar v1.0.3 github.com/gin-contrib/gzip v1.2.5 github.com/gin-contrib/httpsign v1.0.3 github.com/gin-contrib/location/v2 v2.0.0 diff --git a/go.sum b/go.sum index efa4ee1..8d28b4a 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+ github.com/gin-contrib/authz v1.0.6/go.mod h1:A2B5Im1M/HIoHPjLc31j3RlENSE6j8euJY9NFdzZeYo= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/expvar v1.0.3 h1:nIbUaokxZfUEC/35h+RyWCP1SMF/suV/ARbXL3H3jrw= +github.com/gin-contrib/expvar v1.0.3/go.mod h1:bwqqmhty1Zl2JYVLzBIL6CSHDWDbQoQoicalAnBvUnY= github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI= github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw= github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I= diff --git a/options.go b/options.go index a63ca35..820b6a3 100644 --- a/options.go +++ b/options.go @@ -134,6 +134,20 @@ func WithPprof() Option { } } +// WithExpvar enables the Go runtime metrics endpoint at /debug/vars. +// The endpoint serves JSON containing memstats, cmdline, and any +// custom expvar variables registered by the application. Powered by +// gin-contrib/expvar wrapping Go's standard expvar.Handler(). +// +// WARNING: expvar exposes runtime internals (memory allocation, +// goroutine counts, command-line arguments) and should only be +// enabled in development or behind authentication in production. +func WithExpvar() Option { + return func(e *Engine) { + e.expvarEnabled = true + } +} + // WithSecure adds security headers middleware via gin-contrib/secure. // Default policy sets HSTS (1 year, includeSubDomains), X-Frame-Options DENY, // X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin.