diff --git a/middleware.go b/middleware.go index df5307d..e14d3f6 100644 --- a/middleware.go +++ b/middleware.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "net/http" "strings" + "time" "github.com/gin-gonic/gin" ) @@ -14,6 +15,10 @@ import ( // requestIDContextKey is the Gin context key used by requestIDMiddleware. const requestIDContextKey = "request_id" +// requestStartContextKey stores when the request began so handlers can +// calculate elapsed duration for response metadata. +const requestStartContextKey = "request_start" + // bearerAuthMiddleware validates the Authorization: Bearer header. // Requests to paths in the skip list are allowed through without authentication. // Returns 401 with Fail("unauthorised", ...) on missing or invalid tokens. @@ -48,6 +53,8 @@ func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc { // string is generated. The ID is also stored in the Gin context as "request_id". func requestIDMiddleware() gin.HandlerFunc { return func(c *gin.Context) { + c.Set(requestStartContextKey, time.Now()) + id := c.GetHeader("X-Request-ID") if id == "" { b := make([]byte, 16) @@ -71,3 +78,35 @@ func GetRequestID(c *gin.Context) string { } return "" } + +// GetRequestDuration returns the elapsed time since requestIDMiddleware started +// handling the request. Returns 0 when the middleware was not applied. +func GetRequestDuration(c *gin.Context) time.Duration { + if v, ok := c.Get(requestStartContextKey); ok { + if started, ok := v.(time.Time); ok && !started.IsZero() { + return time.Since(started) + } + } + return 0 +} + +// GetRequestMeta returns request metadata collected by requestIDMiddleware. +// The returned meta includes the request ID and elapsed duration when +// available. It returns nil when neither value is available. +func GetRequestMeta(c *gin.Context) *Meta { + meta := &Meta{} + + if id := GetRequestID(c); id != "" { + meta.RequestID = id + } + + if duration := GetRequestDuration(c); duration > 0 { + meta.Duration = duration.String() + } + + if meta.RequestID == "" && meta.Duration == "" { + return nil + } + + return meta +} diff --git a/middleware_test.go b/middleware_test.go index 19ed777..5ee3b5c 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" @@ -39,6 +40,18 @@ func (g requestIDTestGroup) RegisterRoutes(rg *gin.RouterGroup) { }) } +type requestMetaTestGroup struct{} + +func (g requestMetaTestGroup) Name() string { return "request-meta" } +func (g requestMetaTestGroup) BasePath() string { return "/v1" } +func (g requestMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/meta", func(c *gin.Context) { + time.Sleep(2 * time.Millisecond) + resp := api.AttachRequestMeta(c, api.Paginated("classified", 1, 25, 100)) + c.JSON(http.StatusOK, resp) + }) +} + // ── Bearer auth ───────────────────────────────────────────────────────── func TestBearerAuth_Bad_MissingToken(t *testing.T) { @@ -189,6 +202,39 @@ func TestRequestID_Good_ContextAccessor(t *testing.T) { } } +func TestRequestID_Good_RequestMetaHelper(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithRequestID()) + e.Register(requestMetaTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil) + req.Header.Set("X-Request-ID", "client-id-meta") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp api.Response[string] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Meta == nil { + t.Fatal("expected Meta to be present") + } + if resp.Meta.RequestID != "client-id-meta" { + t.Fatalf("expected request_id=%q, got %q", "client-id-meta", resp.Meta.RequestID) + } + if resp.Meta.Duration == "" { + t.Fatal("expected duration to be populated") + } + if resp.Meta.Page != 1 || resp.Meta.PerPage != 25 || resp.Meta.Total != 100 { + t.Fatalf("expected pagination metadata to be preserved, got %+v", resp.Meta) + } +} + // ── CORS ──────────────────────────────────────────────────────────────── func TestCORS_Good_PreflightAllOrigins(t *testing.T) { diff --git a/response.go b/response.go index 2a77e18..ff4b1a6 100644 --- a/response.go +++ b/response.go @@ -2,6 +2,8 @@ package api +import "github.com/gin-gonic/gin" + // Response is the standard envelope for all API responses. type Response[T any] struct { Success bool `json:"success"` @@ -69,3 +71,27 @@ func Paginated[T any](data T, page, perPage, total int) Response[T] { }, } } + +// AttachRequestMeta merges request metadata into an existing response envelope. +// Existing pagination metadata is preserved; request_id and duration are added +// when available from the Gin context. +func AttachRequestMeta[T any](c *gin.Context, resp Response[T]) Response[T] { + meta := GetRequestMeta(c) + if meta == nil { + return resp + } + + if resp.Meta == nil { + resp.Meta = meta + return resp + } + + if resp.Meta.RequestID == "" { + resp.Meta.RequestID = meta.RequestID + } + if resp.Meta.Duration == "" { + resp.Meta.Duration = meta.Duration + } + + return resp +}