feat(api): attach request metadata to responses

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 13:48:53 +00:00
parent 4efa435a47
commit 00a59947b4
3 changed files with 111 additions and 0 deletions

View file

@ -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 <token> 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
}

View file

@ -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) {

View file

@ -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
}