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:
zenfun
2026-01-10 00:59:45 +08:00
parent 6af938448e
commit 26733be020
3 changed files with 238 additions and 32 deletions

60
internal/dto/response.go Normal file
View File

@@ -0,0 +1,60 @@
package dto
// ResponseEnvelope is the standard API response wrapper.
// All JSON API responses are wrapped in this envelope format.
// @Description Standard API response envelope
type ResponseEnvelope struct {
// Business code: 0 for success, non-zero for errors
// 0 = success
// 1xxx = common errors (invalid param, unauthorized, forbidden, rate limited)
// 4xxx = client errors (resource not found, conflict, invalid state)
// 5xxx = server errors (internal error, service unavailable, timeout)
Code int `json:"code" example:"0"`
// Human-readable message: "success" for success, error description for errors
Message string `json:"message" example:"success"`
// Response data: business payload for success, null for errors
Data any `json:"data"`
// Request trace ID for debugging and log correlation
TraceID string `json:"trace_id" example:"a1b2c3d4e5f6g7h8"`
// Optional error details (e.g., field validation errors)
Details any `json:"details,omitempty"`
}
// ErrorResponse is returned when an error occurs.
// @Description Error response format
type ErrorResponse struct {
// Business error code (non-zero)
Code int `json:"code" example:"4001"`
// Error message
Message string `json:"message" example:"resource not found"`
// Always null for errors
Data any `json:"data" example:"null"`
// Request trace ID
TraceID string `json:"trace_id" example:"a1b2c3d4e5f6g7h8"`
// Optional structured error details
Details any `json:"details,omitempty"`
}
// SuccessResponse is returned for successful operations.
// @Description Success response format
type SuccessResponse struct {
// Always 0 for success
Code int `json:"code" example:"0"`
// Always "success"
Message string `json:"message" example:"success"`
// Response payload
Data any `json:"data"`
// Request trace ID
TraceID string `json:"trace_id" example:"a1b2c3d4e5f6g7h8"`
}

View File

@@ -2,6 +2,8 @@ package middleware
import ( import (
"bytes" "bytes"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
@@ -31,11 +33,18 @@ const (
) )
const ( const (
businessCodeKey = "response_business_code" businessCodeKey = "response_business_code"
errorDetailsKey = "response_error_details" errorDetailsKey = "response_error_details"
errorMessageKey = "response_error_message" 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 { type responseEnvelope struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
@@ -122,6 +131,12 @@ func SetErrorMessage(c *gin.Context, message string) {
func ResponseEnvelope() gin.HandlerFunc { func ResponseEnvelope() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// Skip excluded paths
if isExcludedPath(c.Request.URL.Path) {
c.Next()
return
}
originalWriter := c.Writer originalWriter := c.Writer
writer := &envelopeWriter{ResponseWriter: originalWriter} writer := &envelopeWriter{ResponseWriter: originalWriter}
c.Writer = writer 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 var data json.RawMessage
if isError { if isError {
data = json.RawMessage("null") data = json.RawMessage("null")
@@ -186,7 +212,7 @@ func ResponseEnvelope() gin.HandlerFunc {
Message: message, Message: message,
Data: data, Data: data,
TraceID: traceID, TraceID: traceID,
Details: errorDetailsFromContext(c), Details: details,
} }
payload, err := json.Marshal(envelope) payload, err := json.Marshal(envelope)
if err != nil { 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 { func businessCodeFromContext(c *gin.Context) int {
if c == nil { if c == nil {
return 0 return 0
@@ -243,7 +280,7 @@ func errorMessageFromContext(c *gin.Context) string {
func getTraceID(c *gin.Context) string { func getTraceID(c *gin.Context) string {
if c == nil { if c == nil {
return "" return generateTraceID()
} }
// Try to get from context first (set by RequestID middleware) // Try to get from context first (set by RequestID middleware)
if id, ok := c.Get("request_id"); ok { if id, ok := c.Get("request_id"); ok {
@@ -252,7 +289,17 @@ func getTraceID(c *gin.Context) string {
} }
} }
// Fallback to header // 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 { func defaultBusinessCode(status int) int {
@@ -273,6 +320,10 @@ func defaultBusinessCode(status int) int {
return CodeRateLimited return CodeRateLimited
case status >= http.StatusBadRequest && status < http.StatusInternalServerError: case status >= http.StatusBadRequest && status < http.StatusInternalServerError:
return CodeInvalidParam return CodeInvalidParam
case status == http.StatusServiceUnavailable:
return CodeServiceUnavailable
case status == http.StatusGatewayTimeout:
return CodeTimeout
case status >= http.StatusInternalServerError: case status >= http.StatusInternalServerError:
return CodeInternalError return CodeInternalError
default: default:
@@ -311,33 +362,23 @@ func isEnvelopeObject(obj map[string]json.RawMessage) bool {
if obj == nil { if obj == nil {
return false 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"] codeRaw, hasCode := obj["code"]
_, hasData := obj["data"] _, hasData := obj["data"]
_, hasMessage := obj["message"] _, hasMessage := obj["message"]
_, hasTraceID := obj["trace_id"] _, hasTraceID := obj["trace_id"]
if !hasCode || !hasData || !hasMessage { if !hasCode || !hasData || !hasMessage || !hasTraceID {
return false return false
} }
// If has trace_id, it's definitely our envelope // Code must be a number (int)
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
var codeNum int var codeNum int
if json.Unmarshal(codeRaw, &codeNum) == nil { if json.Unmarshal(codeRaw, &codeNum) != nil {
return true return false
}
var codeStr string
if json.Unmarshal(codeRaw, &codeStr) == nil {
return true
} }
return false return true
} }
func writeStatusOnly(w gin.ResponseWriter, status int) { func writeStatusOnly(w gin.ResponseWriter, status int) {

View File

@@ -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) { func TestResponseEnvelope_Idempotent(t *testing.T) {
gin.SetMode(gin.TestMode) 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) gin.SetMode(gin.TestMode)
r := gin.New() r := gin.New()
r.Use(RequestID())
r.Use(ResponseEnvelope()) r.Use(ResponseEnvelope())
r.GET("/old-wrapped", func(c *gin.Context) { 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{ c.JSON(http.StatusOK, gin.H{
"code": "ok", "code": "ok",
"data": gin.H{"id": 1}, "data": gin.H{"id": 1},
@@ -219,17 +252,25 @@ func TestResponseEnvelope_IdempotentOldFormat(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
r.ServeHTTP(rr, req) r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK { var env struct {
t.Fatalf("expected 200, got %d body=%s", rr.Code, rr.Body.String()) 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 { if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil {
t.Fatalf("unmarshal envelope: %v", err) t.Fatalf("unmarshal envelope: %v", err)
} }
// Should pass through unchanged // Old format should be wrapped, so code should be 0 (success)
if env["code"] != "ok" { if env.Code != 0 {
t.Fatalf("expected code='ok', got %v", env["code"]) 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) { func TestResponseEnvelope_NonJSON(t *testing.T) {
gin.SetMode(gin.TestMode) 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) { func TestResponseEnvelope_DefaultCodes(t *testing.T) {
testCases := []struct { testCases := []struct {
status int status int
@@ -294,7 +398,8 @@ func TestResponseEnvelope_DefaultCodes(t *testing.T) {
{http.StatusConflict, CodeResourceConflict}, {http.StatusConflict, CodeResourceConflict},
{http.StatusTooManyRequests, CodeRateLimited}, {http.StatusTooManyRequests, CodeRateLimited},
{http.StatusInternalServerError, CodeInternalError}, {http.StatusInternalServerError, CodeInternalError},
{http.StatusServiceUnavailable, CodeInternalError}, {http.StatusServiceUnavailable, CodeServiceUnavailable},
{http.StatusGatewayTimeout, CodeTimeout},
} }
for _, tc := range testCases { for _, tc := range testCases {