api/response_meta.go

278 lines
5.5 KiB
Go
Raw Permalink Normal View History

// SPDX-License-Identifier: EUPL-1.2
package api
import (
"bufio"
"bytes"
"encoding/json"
"io"
"mime"
"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
committed bool
passthrough 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) {
if w.passthrough {
w.status = code
w.wroteHeader = true
w.ResponseWriter.WriteHeader(code)
return
}
w.status = code
w.wroteHeader = true
}
func (w *responseMetaRecorder) WriteHeaderNow() {
if w.passthrough {
w.wroteHeader = true
w.ResponseWriter.WriteHeaderNow()
return
}
w.wroteHeader = true
}
func (w *responseMetaRecorder) Write(data []byte) (int, error) {
if w.passthrough {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(data)
}
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.Write(data)
}
func (w *responseMetaRecorder) WriteString(s string) (int, error) {
if w.passthrough {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.WriteString(s)
}
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.WriteString(s)
}
func (w *responseMetaRecorder) Flush() {
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()
}
}
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) {
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()
}
return nil, nil, io.ErrClosedPipe
}
func (w *responseMetaRecorder) commit(writeBody bool) {
if w.committed {
return
}
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())
if writeBody {
_, _ = w.ResponseWriter.Write(w.body.Bytes())
w.body.Reset()
}
w.committed = true
}
// responseMetaMiddleware injects request metadata into 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()
if recorder.passthrough {
return
}
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(true)
}
}
// 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 {
if _, ok := obj["error"]; !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 !isJSONContentType(contentType) {
return false
}
trimmed := bytes.TrimSpace(body)
return len(trimmed) > 0 && trimmed[0] == '{'
}
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")
}