mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(api): standardize response envelope behavior
Add shared response DTOs and enhance the response envelope middleware with excluded paths, trace ID generation fallback, and automatic extraction of error details from handler responses. Update default business code mapping for 503 and 504, and adjust idempotency detection to only treat the new envelope format as already-wrapped. BREAKING CHANGE: responses using the old envelope format (e.g., string `code`) are now wrapped into the new standard envelope.
This commit is contained in:
@@ -2,6 +2,8 @@ package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -31,11 +33,18 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
businessCodeKey = "response_business_code"
|
||||
errorDetailsKey = "response_error_details"
|
||||
errorMessageKey = "response_error_message"
|
||||
businessCodeKey = "response_business_code"
|
||||
errorDetailsKey = "response_error_details"
|
||||
errorMessageKey = "response_error_message"
|
||||
)
|
||||
|
||||
// Paths that should not be wrapped
|
||||
var excludedPaths = map[string]bool{
|
||||
"/health": true,
|
||||
"/debug/vars": true,
|
||||
"/internal/metrics": true,
|
||||
}
|
||||
|
||||
type responseEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
@@ -122,6 +131,12 @@ func SetErrorMessage(c *gin.Context, message string) {
|
||||
|
||||
func ResponseEnvelope() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Skip excluded paths
|
||||
if isExcludedPath(c.Request.URL.Path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
originalWriter := c.Writer
|
||||
writer := &envelopeWriter{ResponseWriter: originalWriter}
|
||||
c.Writer = writer
|
||||
@@ -174,6 +189,17 @@ func ResponseEnvelope() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Get details: from context first, then from original response
|
||||
details := errorDetailsFromContext(c)
|
||||
if details == nil && isError && objOK {
|
||||
if raw, ok := obj["details"]; ok {
|
||||
var d any
|
||||
if err := json.Unmarshal(raw, &d); err == nil {
|
||||
details = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var data json.RawMessage
|
||||
if isError {
|
||||
data = json.RawMessage("null")
|
||||
@@ -186,7 +212,7 @@ func ResponseEnvelope() gin.HandlerFunc {
|
||||
Message: message,
|
||||
Data: data,
|
||||
TraceID: traceID,
|
||||
Details: errorDetailsFromContext(c),
|
||||
Details: details,
|
||||
}
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
@@ -200,6 +226,17 @@ func ResponseEnvelope() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func isExcludedPath(path string) bool {
|
||||
if excludedPaths[path] {
|
||||
return true
|
||||
}
|
||||
// Also exclude swagger paths
|
||||
if strings.HasPrefix(path, "/swagger") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func businessCodeFromContext(c *gin.Context) int {
|
||||
if c == nil {
|
||||
return 0
|
||||
@@ -243,7 +280,7 @@ func errorMessageFromContext(c *gin.Context) string {
|
||||
|
||||
func getTraceID(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
return generateTraceID()
|
||||
}
|
||||
// Try to get from context first (set by RequestID middleware)
|
||||
if id, ok := c.Get("request_id"); ok {
|
||||
@@ -252,7 +289,17 @@ func getTraceID(c *gin.Context) string {
|
||||
}
|
||||
}
|
||||
// Fallback to header
|
||||
return c.GetHeader("X-Request-ID")
|
||||
if id := c.GetHeader("X-Request-ID"); id != "" {
|
||||
return id
|
||||
}
|
||||
// Generate UUID as fallback
|
||||
return generateTraceID()
|
||||
}
|
||||
|
||||
func generateTraceID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func defaultBusinessCode(status int) int {
|
||||
@@ -273,6 +320,10 @@ func defaultBusinessCode(status int) int {
|
||||
return CodeRateLimited
|
||||
case status >= http.StatusBadRequest && status < http.StatusInternalServerError:
|
||||
return CodeInvalidParam
|
||||
case status == http.StatusServiceUnavailable:
|
||||
return CodeServiceUnavailable
|
||||
case status == http.StatusGatewayTimeout:
|
||||
return CodeTimeout
|
||||
case status >= http.StatusInternalServerError:
|
||||
return CodeInternalError
|
||||
default:
|
||||
@@ -311,33 +362,23 @@ func isEnvelopeObject(obj map[string]json.RawMessage) bool {
|
||||
if obj == nil {
|
||||
return false
|
||||
}
|
||||
// Check for new envelope format (code is number, has trace_id)
|
||||
// Only recognize new envelope format: must have trace_id and code must be number
|
||||
codeRaw, hasCode := obj["code"]
|
||||
_, hasData := obj["data"]
|
||||
_, hasMessage := obj["message"]
|
||||
_, hasTraceID := obj["trace_id"]
|
||||
|
||||
if !hasCode || !hasData || !hasMessage {
|
||||
if !hasCode || !hasData || !hasMessage || !hasTraceID {
|
||||
return false
|
||||
}
|
||||
|
||||
// If has trace_id, it's definitely our envelope
|
||||
if hasTraceID {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if code is a number (new format) or string (old format)
|
||||
// Both should be treated as envelope to avoid double-wrapping
|
||||
// Code must be a number (int)
|
||||
var codeNum int
|
||||
if json.Unmarshal(codeRaw, &codeNum) == nil {
|
||||
return true
|
||||
}
|
||||
var codeStr string
|
||||
if json.Unmarshal(codeRaw, &codeStr) == nil {
|
||||
return true
|
||||
if json.Unmarshal(codeRaw, &codeNum) != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
func writeStatusOnly(w gin.ResponseWriter, status int) {
|
||||
|
||||
@@ -163,6 +163,38 @@ func TestResponseEnvelope_WithDetails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_AutoExtractDetails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(RequestID())
|
||||
r.Use(ResponseEnvelope())
|
||||
r.POST("/validate", func(c *gin.Context) {
|
||||
// Handler returns details in response, middleware should extract it
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "参数校验失败",
|
||||
"details": map[string]string{"email": "格式错误"},
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/validate", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
var env struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
Details map[string]any `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if env.Details["email"] != "格式错误" {
|
||||
t.Fatalf("expected details.email='格式错误', got %v", env.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_Idempotent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -201,13 +233,14 @@ func TestResponseEnvelope_Idempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_IdempotentOldFormat(t *testing.T) {
|
||||
func TestResponseEnvelope_OldFormatGetsWrapped(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(RequestID())
|
||||
r.Use(ResponseEnvelope())
|
||||
r.GET("/old-wrapped", func(c *gin.Context) {
|
||||
// Old format with string code
|
||||
// Old format with string code - should be wrapped now
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": "ok",
|
||||
"data": gin.H{"id": 1},
|
||||
@@ -219,17 +252,25 @@ func TestResponseEnvelope_IdempotentOldFormat(t *testing.T) {
|
||||
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 struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]any `json:"data"`
|
||||
TraceID string `json:"trace_id"`
|
||||
}
|
||||
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
// Should pass through unchanged
|
||||
if env["code"] != "ok" {
|
||||
t.Fatalf("expected code='ok', got %v", env["code"])
|
||||
// Old format should be wrapped, so code should be 0 (success)
|
||||
if env.Code != 0 {
|
||||
t.Fatalf("expected code=0, got %d", env.Code)
|
||||
}
|
||||
// Data should contain the old response
|
||||
if env.Data["code"] != "ok" {
|
||||
t.Fatalf("expected data.code='ok', got %v", env.Data["code"])
|
||||
}
|
||||
if env.TraceID == "" {
|
||||
t.Fatal("expected trace_id to be set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +300,34 @@ func TestResponseEnvelope_TraceIDFromHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_TraceIDFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
// No RequestID middleware
|
||||
r.Use(ResponseEnvelope())
|
||||
r.GET("/trace", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/trace", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
var env struct {
|
||||
TraceID string `json:"trace_id"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if env.TraceID == "" {
|
||||
t.Fatal("expected trace_id to be generated")
|
||||
}
|
||||
if len(env.TraceID) != 32 {
|
||||
t.Fatalf("expected 32-char hex trace_id, got %q", env.TraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_NonJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -280,6 +349,41 @@ func TestResponseEnvelope_NonJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_ExcludedPaths(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(ResponseEnvelope())
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
r.GET("/swagger/doc.json", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"openapi": "3.0.0"})
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
}{
|
||||
{"/health"},
|
||||
{"/swagger/doc.json"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response for %s: %v", tt.path, err)
|
||||
}
|
||||
// Should not have envelope fields
|
||||
if _, hasCode := resp["code"]; hasCode {
|
||||
t.Fatalf("expected %s to not be wrapped, but found 'code' field", tt.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseEnvelope_DefaultCodes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
status int
|
||||
@@ -294,7 +398,8 @@ func TestResponseEnvelope_DefaultCodes(t *testing.T) {
|
||||
{http.StatusConflict, CodeResourceConflict},
|
||||
{http.StatusTooManyRequests, CodeRateLimited},
|
||||
{http.StatusInternalServerError, CodeInternalError},
|
||||
{http.StatusServiceUnavailable, CodeInternalError},
|
||||
{http.StatusServiceUnavailable, CodeServiceUnavailable},
|
||||
{http.StatusGatewayTimeout, CodeTimeout},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
||||
Reference in New Issue
Block a user