feat(api): auto-attach request metadata
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
37b7fd21ae
commit
c4cbd018ac
6 changed files with 254 additions and 42 deletions
42
cache.go
42
cache.go
|
|
@ -5,8 +5,6 @@ package api
|
|||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
|
@ -185,43 +183,5 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
|
|||
// Non-JSON bodies, malformed JSON, and responses without a top-level object are
|
||||
// returned unchanged.
|
||||
func refreshCachedResponseMeta(body []byte, meta *Meta) []byte {
|
||||
if meta == nil {
|
||||
return body
|
||||
}
|
||||
|
||||
var payload any
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&payload); err != nil {
|
||||
return body
|
||||
}
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err != io.EOF {
|
||||
return body
|
||||
}
|
||||
|
||||
obj, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return body
|
||||
}
|
||||
|
||||
current := map[string]any{}
|
||||
if existing, ok := obj["meta"].(map[string]any); ok {
|
||||
current = existing
|
||||
}
|
||||
|
||||
if meta.RequestID != "" {
|
||||
current["request_id"] = meta.RequestID
|
||||
}
|
||||
if meta.Duration != "" {
|
||||
current["duration"] = meta.Duration
|
||||
}
|
||||
|
||||
obj["meta"] = current
|
||||
|
||||
updated, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return updated
|
||||
return refreshResponseMetaBody(body, meta)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ They execute after `gin.Recovery()` but before any route handler. The `Option` t
|
|||
| `WithAddr(addr)` | Listen address | Default `:8080` |
|
||||
| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` |
|
||||
| `WithRequestID()` | `X-Request-ID` propagation | Preserves client-supplied IDs; generates 16-byte hex otherwise |
|
||||
| `WithResponseMeta()` | Request metadata in JSON envelopes | Merges `request_id` and `duration` into standard responses |
|
||||
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` |
|
||||
| `WithRateLimit(limit)` | Per-IP token-bucket rate limiting | `429 Too Many Requests`; `Retry-After` on rejection; zero or negative disables |
|
||||
| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch for custom middleware |
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ engine.Register(&Routes{service: svc})
|
|||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
|
||||
| `options.go` | All `With*()` option functions (26 options) |
|
||||
| `options.go` | All `With*()` option functions (27 options) |
|
||||
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
|
||||
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
|
||||
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ func (g requestMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
|||
})
|
||||
}
|
||||
|
||||
type autoResponseMetaTestGroup struct{}
|
||||
|
||||
func (g autoResponseMetaTestGroup) Name() string { return "auto-response-meta" }
|
||||
func (g autoResponseMetaTestGroup) BasePath() string { return "/v1" }
|
||||
func (g autoResponseMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("/meta", func(c *gin.Context) {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
c.JSON(http.StatusOK, api.Paginated("classified", 1, 25, 100))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Bearer auth ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestBearerAuth_Bad_MissingToken(t *testing.T) {
|
||||
|
|
@ -235,6 +246,45 @@ func TestRequestID_Good_RequestMetaHelper(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResponseMeta_Good_AttachesMetaAutomatically(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
e, _ := api.New(
|
||||
api.WithRequestID(),
|
||||
api.WithResponseMeta(),
|
||||
)
|
||||
e.Register(autoResponseMetaTestGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
|
||||
req.Header.Set("X-Request-ID", "client-id-auto-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-auto-meta" {
|
||||
t.Fatalf("expected request_id=%q, got %q", "client-id-auto-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)
|
||||
}
|
||||
if got := w.Header().Get("X-Request-ID"); got != "client-id-auto-meta" {
|
||||
t.Fatalf("expected response header X-Request-ID=%q, got %q", "client-id-auto-meta", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CORS ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCORS_Good_PreflightAllOrigins(t *testing.T) {
|
||||
|
|
|
|||
10
options.go
10
options.go
|
|
@ -52,6 +52,16 @@ func WithRequestID() Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithResponseMeta attaches request metadata to JSON envelope responses.
|
||||
// It preserves any existing pagination metadata and merges in request_id
|
||||
// and duration when available from the request context. Combine it with
|
||||
// WithRequestID() to populate both fields automatically.
|
||||
func WithResponseMeta() Option {
|
||||
return func(e *Engine) {
|
||||
e.middlewares = append(e.middlewares, responseMetaMiddleware())
|
||||
}
|
||||
}
|
||||
|
||||
// WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors.
|
||||
// Pass "*" to allow all origins, or supply specific origin URLs.
|
||||
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common
|
||||
|
|
|
|||
191
response_meta.go
Normal file
191
response_meta.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// responseMetaRecorder buffers JSON responses so request metadata can be
|
||||
// injected into the standard envelope before the body is written to the client.
|
||||
type responseMetaRecorder struct {
|
||||
gin.ResponseWriter
|
||||
headers http.Header
|
||||
body bytes.Buffer
|
||||
status int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func newResponseMetaRecorder(w gin.ResponseWriter) *responseMetaRecorder {
|
||||
headers := make(http.Header)
|
||||
for k, vals := range w.Header() {
|
||||
headers[k] = append([]string(nil), vals...)
|
||||
}
|
||||
|
||||
return &responseMetaRecorder{
|
||||
ResponseWriter: w,
|
||||
headers: headers,
|
||||
status: http.StatusOK,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) Header() http.Header {
|
||||
return w.headers
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.wroteHeader = true
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) WriteHeaderNow() {
|
||||
w.wroteHeader = true
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) Write(data []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.body.Write(data)
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) WriteString(s string) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.body.WriteString(s)
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) Flush() {
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) Status() int {
|
||||
if w.wroteHeader {
|
||||
return w.status
|
||||
}
|
||||
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) Size() int {
|
||||
return w.body.Len()
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) Written() bool {
|
||||
return w.wroteHeader
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
return nil, nil, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
func (w *responseMetaRecorder) commit() {
|
||||
for k := range w.ResponseWriter.Header() {
|
||||
w.ResponseWriter.Header().Del(k)
|
||||
}
|
||||
|
||||
for k, vals := range w.headers {
|
||||
for _, v := range vals {
|
||||
w.ResponseWriter.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
w.ResponseWriter.WriteHeader(w.Status())
|
||||
_, _ = w.ResponseWriter.Write(w.body.Bytes())
|
||||
}
|
||||
|
||||
// responseMetaMiddleware injects request metadata into successful JSON
|
||||
// envelope responses before they are written to the client.
|
||||
func responseMetaMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, ok := c.Get(requestStartContextKey); !ok {
|
||||
c.Set(requestStartContextKey, time.Now())
|
||||
}
|
||||
|
||||
recorder := newResponseMetaRecorder(c.Writer)
|
||||
c.Writer = recorder
|
||||
|
||||
c.Next()
|
||||
|
||||
body := recorder.body.Bytes()
|
||||
if meta := GetRequestMeta(c); meta != nil && shouldAttachResponseMeta(recorder.Header().Get("Content-Type"), body) {
|
||||
if refreshed := refreshResponseMetaBody(body, meta); refreshed != nil {
|
||||
body = refreshed
|
||||
}
|
||||
}
|
||||
|
||||
recorder.body.Reset()
|
||||
_, _ = recorder.body.Write(body)
|
||||
recorder.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
recorder.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// refreshResponseMetaBody injects request metadata into a cached or buffered
|
||||
// JSON envelope without disturbing existing pagination metadata.
|
||||
func refreshResponseMetaBody(body []byte, meta *Meta) []byte {
|
||||
if meta == nil {
|
||||
return body
|
||||
}
|
||||
|
||||
var payload any
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&payload); err != nil {
|
||||
return body
|
||||
}
|
||||
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err != io.EOF {
|
||||
return body
|
||||
}
|
||||
|
||||
obj, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return body
|
||||
}
|
||||
|
||||
if _, ok := obj["success"]; !ok {
|
||||
return body
|
||||
}
|
||||
|
||||
current := map[string]any{}
|
||||
if existing, ok := obj["meta"].(map[string]any); ok {
|
||||
current = existing
|
||||
}
|
||||
|
||||
if meta.RequestID != "" {
|
||||
current["request_id"] = meta.RequestID
|
||||
}
|
||||
if meta.Duration != "" {
|
||||
current["duration"] = meta.Duration
|
||||
}
|
||||
|
||||
obj["meta"] = current
|
||||
|
||||
updated, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
func shouldAttachResponseMeta(contentType string, body []byte) bool {
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
return false
|
||||
}
|
||||
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
return len(trimmed) > 0 && trimmed[0] == '{'
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue