2026-04-01 14:00:04 +00:00
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
|
|
|
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"bytes"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
2026-04-02 03:38:34 +00:00
|
|
|
"mime"
|
2026-04-01 14:00:04 +00:00
|
|
|
"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
|
2026-04-02 06:04:06 +00:00
|
|
|
committed bool
|
|
|
|
|
passthrough bool
|
2026-04-01 14:00:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-04-02 06:04:06 +00:00
|
|
|
if w.passthrough {
|
|
|
|
|
w.status = code
|
|
|
|
|
w.wroteHeader = true
|
|
|
|
|
w.ResponseWriter.WriteHeader(code)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-01 14:00:04 +00:00
|
|
|
w.status = code
|
|
|
|
|
w.wroteHeader = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (w *responseMetaRecorder) WriteHeaderNow() {
|
2026-04-02 06:04:06 +00:00
|
|
|
if w.passthrough {
|
|
|
|
|
w.wroteHeader = true
|
|
|
|
|
w.ResponseWriter.WriteHeaderNow()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-01 14:00:04 +00:00
|
|
|
w.wroteHeader = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (w *responseMetaRecorder) Write(data []byte) (int, error) {
|
2026-04-02 06:04:06 +00:00
|
|
|
if w.passthrough {
|
|
|
|
|
if !w.wroteHeader {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
return w.ResponseWriter.Write(data)
|
|
|
|
|
}
|
2026-04-01 14:00:04 +00:00
|
|
|
if !w.wroteHeader {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
return w.body.Write(data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (w *responseMetaRecorder) WriteString(s string) (int, error) {
|
2026-04-02 06:04:06 +00:00
|
|
|
if w.passthrough {
|
|
|
|
|
if !w.wroteHeader {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
return w.ResponseWriter.WriteString(s)
|
|
|
|
|
}
|
2026-04-01 14:00:04 +00:00
|
|
|
if !w.wroteHeader {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
return w.body.WriteString(s)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (w *responseMetaRecorder) Flush() {
|
2026-04-02 06:04:06 +00:00
|
|
|
if w.passthrough {
|
|
|
|
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
|
|
|
f.Flush()
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !w.wroteHeader {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.commit(true)
|
|
|
|
|
w.passthrough = true
|
|
|
|
|
|
|
|
|
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
|
|
|
f.Flush()
|
|
|
|
|
}
|
2026-04-01 14:00:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-04-02 06:04:06 +00:00
|
|
|
if w.passthrough {
|
|
|
|
|
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
|
|
|
|
|
return h.Hijack()
|
|
|
|
|
}
|
|
|
|
|
return nil, nil, io.ErrClosedPipe
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.wroteHeader = true
|
|
|
|
|
w.passthrough = true
|
|
|
|
|
|
|
|
|
|
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
|
|
|
|
|
return h.Hijack()
|
|
|
|
|
}
|
2026-04-01 14:00:04 +00:00
|
|
|
return nil, nil, io.ErrClosedPipe
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 06:04:06 +00:00
|
|
|
func (w *responseMetaRecorder) commit(writeBody bool) {
|
|
|
|
|
if w.committed {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 14:00:04 +00:00
|
|
|
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())
|
2026-04-02 06:04:06 +00:00
|
|
|
if writeBody {
|
|
|
|
|
_, _ = w.ResponseWriter.Write(w.body.Bytes())
|
|
|
|
|
w.body.Reset()
|
|
|
|
|
}
|
|
|
|
|
w.committed = true
|
2026-04-01 14:00:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 17:43:37 +00:00
|
|
|
// responseMetaMiddleware injects request metadata into JSON envelope
|
|
|
|
|
// responses before they are written to the client.
|
2026-04-01 14:00:04 +00:00
|
|
|
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()
|
|
|
|
|
|
2026-04-02 06:04:06 +00:00
|
|
|
if recorder.passthrough {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 14:00:04 +00:00
|
|
|
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)))
|
2026-04-02 06:04:06 +00:00
|
|
|
recorder.commit(true)
|
2026-04-01 14:00:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-04-01 17:43:37 +00:00
|
|
|
if _, ok := obj["error"]; !ok {
|
|
|
|
|
return body
|
|
|
|
|
}
|
2026-04-01 14:00:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-04-02 03:38:34 +00:00
|
|
|
if !isJSONContentType(contentType) {
|
2026-04-01 14:00:04 +00:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
trimmed := bytes.TrimSpace(body)
|
|
|
|
|
return len(trimmed) > 0 && trimmed[0] == '{'
|
|
|
|
|
}
|
2026-04-02 03:38:34 +00:00
|
|
|
|
|
|
|
|
func isJSONContentType(contentType string) bool {
|
|
|
|
|
if strings.TrimSpace(contentType) == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
|
|
|
if err != nil {
|
|
|
|
|
mediaType = strings.TrimSpace(contentType)
|
|
|
|
|
}
|
|
|
|
|
mediaType = strings.ToLower(mediaType)
|
|
|
|
|
|
|
|
|
|
return mediaType == "application/json" ||
|
|
|
|
|
strings.HasSuffix(mediaType, "+json") ||
|
|
|
|
|
strings.HasSuffix(mediaType, "/json")
|
|
|
|
|
}
|