feat(api): attach request metadata to responses
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
4efa435a47
commit
00a59947b4
3 changed files with 111 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
26
response.go
26
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue