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"
|
|
|
|
|
"time"
|
|
|
|
|
|
refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.
Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00
|
|
|
core "dappco.re/go/core"
|
|
|
|
|
|
2026-04-01 14:00:04 +00:00
|
|
|
"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
|
2026-04-07 08:38:41 +01:00
|
|
|
size int
|
2026-04-01 14:00:04 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-04-07 08:38:41 +01:00
|
|
|
n, err := w.body.Write(data)
|
|
|
|
|
w.size += n
|
|
|
|
|
return n, err
|
2026-04-01 14:00:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-04-07 08:38:41 +01:00
|
|
|
n, err := w.body.WriteString(s)
|
|
|
|
|
w.size += n
|
|
|
|
|
return n, err
|
2026-04-01 14:00:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-04-07 08:38:41 +01:00
|
|
|
if w.passthrough {
|
|
|
|
|
return w.ResponseWriter.Size()
|
|
|
|
|
}
|
|
|
|
|
return w.size
|
2026-04-01 14:00:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-07 08:38:41 +01:00
|
|
|
recorder.size = len(body)
|
2026-04-01 14:00:04 +00:00
|
|
|
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 {
|
refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.
Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00
|
|
|
if core.Trim(contentType) == "" {
|
2026-04-02 03:38:34 +00:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
|
|
|
if err != nil {
|
refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.
Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00
|
|
|
mediaType = core.Trim(contentType)
|
2026-04-02 03:38:34 +00:00
|
|
|
}
|
refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.
Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00
|
|
|
mediaType = core.Lower(mediaType)
|
2026-04-02 03:38:34 +00:00
|
|
|
|
|
|
|
|
return mediaType == "application/json" ||
|
refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.
Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00
|
|
|
core.HasSuffix(mediaType, "+json") ||
|
|
|
|
|
core.HasSuffix(mediaType, "/json")
|
2026-04-02 03:38:34 +00:00
|
|
|
}
|