mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(api): wrap JSON responses in envelope
Add response envelope middleware to standardize JSON responses as
`{code,data,message}` with consistent business codes across endpoints.
Update Swagger annotations and tests to reflect the new response shape.
BREAKING CHANGE: API responses are now wrapped in a response envelope; clients must read payloads from `data` and handle `code`/`message` fields.
This commit is contained in:
233
internal/middleware/response_envelope.go
Normal file
233
internal/middleware/response_envelope.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const businessCodeKey = "response_business_code"
|
||||
|
||||
type responseEnvelope struct {
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type envelopeWriter struct {
|
||||
gin.ResponseWriter
|
||||
body bytes.Buffer
|
||||
status int
|
||||
size int
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.wrote = true
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) WriteHeaderNow() {
|
||||
if !w.wrote {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) Write(data []byte) (int, error) {
|
||||
if !w.wrote {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
n, err := w.body.Write(data)
|
||||
w.size += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) WriteString(s string) (int, error) {
|
||||
if !w.wrote {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
n, err := w.body.WriteString(s)
|
||||
w.size += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) Status() int {
|
||||
if w.status == 0 {
|
||||
return http.StatusOK
|
||||
}
|
||||
return w.status
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) Size() int {
|
||||
return w.size
|
||||
}
|
||||
|
||||
func (w *envelopeWriter) Written() bool {
|
||||
return w.wrote
|
||||
}
|
||||
|
||||
// SetBusinessCode sets an explicit business code for the response envelope.
|
||||
func SetBusinessCode(c *gin.Context, code string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return
|
||||
}
|
||||
c.Set(businessCodeKey, code)
|
||||
}
|
||||
|
||||
func ResponseEnvelope() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
originalWriter := c.Writer
|
||||
writer := &envelopeWriter{ResponseWriter: originalWriter}
|
||||
c.Writer = writer
|
||||
|
||||
c.Next()
|
||||
|
||||
status := writer.Status()
|
||||
body := writer.body.Bytes()
|
||||
if !bodyAllowedForStatus(status) || len(body) == 0 {
|
||||
writeStatusOnly(originalWriter, status)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := originalWriter.Header().Get("Content-Type")
|
||||
if !isJSONContentType(contentType) {
|
||||
writeThrough(originalWriter, status, body)
|
||||
return
|
||||
}
|
||||
|
||||
obj, objOK := parseObject(body)
|
||||
if objOK && isEnvelopeObject(obj) {
|
||||
writeThrough(originalWriter, status, body)
|
||||
return
|
||||
}
|
||||
|
||||
code := businessCodeFromContext(c)
|
||||
if code == "" {
|
||||
code = defaultBusinessCode(status)
|
||||
}
|
||||
|
||||
message := ""
|
||||
if status >= http.StatusBadRequest && objOK {
|
||||
if raw, ok := obj["error"]; ok {
|
||||
var msg string
|
||||
if err := json.Unmarshal(raw, &msg); err == nil {
|
||||
message = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
envelope := responseEnvelope{
|
||||
Code: code,
|
||||
Data: json.RawMessage(body),
|
||||
Message: message,
|
||||
}
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
writeThrough(originalWriter, status, body)
|
||||
return
|
||||
}
|
||||
|
||||
originalWriter.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
originalWriter.Header().Del("Content-Length")
|
||||
writeThrough(originalWriter, status, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func businessCodeFromContext(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
value, ok := c.Get(businessCodeKey)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
code, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(code)
|
||||
}
|
||||
|
||||
func defaultBusinessCode(status int) string {
|
||||
switch {
|
||||
case status >= http.StatusOK && status < http.StatusMultipleChoices:
|
||||
return "ok"
|
||||
case status == http.StatusBadRequest:
|
||||
return "invalid_request"
|
||||
case status == http.StatusUnauthorized:
|
||||
return "unauthorized"
|
||||
case status == http.StatusForbidden:
|
||||
return "forbidden"
|
||||
case status == http.StatusNotFound:
|
||||
return "not_found"
|
||||
case status == http.StatusConflict:
|
||||
return "conflict"
|
||||
case status == http.StatusTooManyRequests:
|
||||
return "rate_limited"
|
||||
case status >= http.StatusBadRequest && status < http.StatusInternalServerError:
|
||||
return "request_error"
|
||||
case status >= http.StatusInternalServerError:
|
||||
return "internal_error"
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
|
||||
func bodyAllowedForStatus(status int) bool {
|
||||
switch {
|
||||
case status >= 100 && status <= 199:
|
||||
return false
|
||||
case status == http.StatusNoContent:
|
||||
return false
|
||||
case status == http.StatusNotModified:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isJSONContentType(contentType string) bool {
|
||||
if contentType == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(contentType), "application/json")
|
||||
}
|
||||
|
||||
func parseObject(body []byte) (map[string]json.RawMessage, bool) {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &obj); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return obj, true
|
||||
}
|
||||
|
||||
func isEnvelopeObject(obj map[string]json.RawMessage) bool {
|
||||
if obj == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := obj["code"]; !ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := obj["data"]; !ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := obj["message"]; !ok {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeStatusOnly(w gin.ResponseWriter, status int) {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
|
||||
func writeThrough(w gin.ResponseWriter, status int, body []byte) {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
109
internal/middleware/response_envelope_test.go
Normal file
109
internal/middleware/response_envelope_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestResponseEnvelope_DefaultMapping(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(ResponseEnvelope())
|
||||
r.GET("/missing", func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not here"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/missing", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var env struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if env.Code != "not_found" {
|
||||
t.Fatalf("expected code=not_found, got %q", env.Code)
|
||||
}
|
||||
if env.Message != "not here" {
|
||||
t.Fatalf("expected message 'not here', got %q", env.Message)
|
||||
}
|
||||
if env.Data["error"] != "not here" {
|
||||
t.Fatalf("expected data.error 'not here', got %v", env.Data["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_OverrideBusinessCode(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(ResponseEnvelope())
|
||||
r.GET("/rate-limit", func(c *gin.Context) {
|
||||
SetBusinessCode(c, "quota_exceeded")
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limited"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/rate-limit", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var env struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if env.Code != "quota_exceeded" {
|
||||
t.Fatalf("expected code=quota_exceeded, got %q", env.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_Idempotent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(ResponseEnvelope())
|
||||
r.GET("/wrapped", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": "ok",
|
||||
"data": gin.H{"id": 1},
|
||||
"message": "",
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/wrapped", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if env["code"] != "ok" {
|
||||
t.Fatalf("expected code=ok, got %v", env["code"])
|
||||
}
|
||||
data, ok := env["data"].(map[string]any)
|
||||
if !ok || data["id"] != float64(1) {
|
||||
t.Fatalf("unexpected data: %+v", env["data"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user