mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(api): enrich response envelope metadata
Add numeric business codes, include `trace_id`, and support custom error messages and `details` for error responses while keeping envelope wrapping idempotent across old and new formats. BREAKING CHANGE: response envelope `code` changes from string to int and envelope format now includes `trace_id` (and may include `details`).
This commit is contained in:
@@ -9,12 +9,39 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const businessCodeKey = "response_business_code"
|
||||
// Business code constants
|
||||
const (
|
||||
CodeSuccess = 0
|
||||
|
||||
// Common errors (1xxx)
|
||||
CodeInvalidParam = 1001
|
||||
CodeUnauthorized = 1002
|
||||
CodeForbidden = 1003
|
||||
CodeRateLimited = 1004
|
||||
|
||||
// Client errors (4xxx)
|
||||
CodeResourceNotFound = 4001
|
||||
CodeResourceConflict = 4002
|
||||
CodeInvalidState = 4003
|
||||
|
||||
// Server errors (5xxx)
|
||||
CodeInternalError = 5001
|
||||
CodeServiceUnavailable = 5002
|
||||
CodeTimeout = 5003
|
||||
)
|
||||
|
||||
const (
|
||||
businessCodeKey = "response_business_code"
|
||||
errorDetailsKey = "response_error_details"
|
||||
errorMessageKey = "response_error_message"
|
||||
)
|
||||
|
||||
type responseEnvelope struct {
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
TraceID string `json:"trace_id"`
|
||||
Details any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type envelopeWriter struct {
|
||||
@@ -70,15 +97,27 @@ func (w *envelopeWriter) Written() bool {
|
||||
}
|
||||
|
||||
// SetBusinessCode sets an explicit business code for the response envelope.
|
||||
func SetBusinessCode(c *gin.Context, code string) {
|
||||
func SetBusinessCode(c *gin.Context, code int) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
c.Set(businessCodeKey, code)
|
||||
}
|
||||
|
||||
// SetErrorDetails sets additional error details for the response envelope.
|
||||
func SetErrorDetails(c *gin.Context, details any) {
|
||||
if c == nil || details == nil {
|
||||
return
|
||||
}
|
||||
c.Set(businessCodeKey, code)
|
||||
c.Set(errorDetailsKey, details)
|
||||
}
|
||||
|
||||
// SetErrorMessage sets a custom error message for the response envelope.
|
||||
func SetErrorMessage(c *gin.Context, message string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.Set(errorMessageKey, message)
|
||||
}
|
||||
|
||||
func ResponseEnvelope() gin.HandlerFunc {
|
||||
@@ -109,12 +148,17 @@ func ResponseEnvelope() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
code := businessCodeFromContext(c)
|
||||
if code == "" {
|
||||
if code == 0 {
|
||||
code = defaultBusinessCode(status)
|
||||
}
|
||||
|
||||
message := ""
|
||||
if status >= http.StatusBadRequest && objOK {
|
||||
traceID := getTraceID(c)
|
||||
isError := status >= http.StatusBadRequest
|
||||
|
||||
var message string
|
||||
if customMsg := errorMessageFromContext(c); customMsg != "" {
|
||||
message = customMsg
|
||||
} else if isError && objOK {
|
||||
if raw, ok := obj["error"]; ok {
|
||||
var msg string
|
||||
if err := json.Unmarshal(raw, &msg); err == nil {
|
||||
@@ -122,11 +166,27 @@ func ResponseEnvelope() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
if message == "" {
|
||||
if isError {
|
||||
message = http.StatusText(status)
|
||||
} else {
|
||||
message = "success"
|
||||
}
|
||||
}
|
||||
|
||||
var data json.RawMessage
|
||||
if isError {
|
||||
data = json.RawMessage("null")
|
||||
} else {
|
||||
data = json.RawMessage(body)
|
||||
}
|
||||
|
||||
envelope := responseEnvelope{
|
||||
Code: code,
|
||||
Data: json.RawMessage(body),
|
||||
Message: message,
|
||||
Data: data,
|
||||
TraceID: traceID,
|
||||
Details: errorDetailsFromContext(c),
|
||||
}
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
@@ -140,43 +200,83 @@ func ResponseEnvelope() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func businessCodeFromContext(c *gin.Context) string {
|
||||
func businessCodeFromContext(c *gin.Context) int {
|
||||
if c == nil {
|
||||
return ""
|
||||
return 0
|
||||
}
|
||||
value, ok := c.Get(businessCodeKey)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
code, ok := value.(int)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func errorDetailsFromContext(c *gin.Context) any {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
value, ok := c.Get(errorDetailsKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func errorMessageFromContext(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
code, ok := value.(string)
|
||||
value, ok := c.Get(errorMessageKey)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(code)
|
||||
msg, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func defaultBusinessCode(status int) string {
|
||||
func getTraceID(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
// Try to get from context first (set by RequestID middleware)
|
||||
if id, ok := c.Get("request_id"); ok {
|
||||
if s, ok := id.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
// Fallback to header
|
||||
return c.GetHeader("X-Request-ID")
|
||||
}
|
||||
|
||||
func defaultBusinessCode(status int) int {
|
||||
switch {
|
||||
case status >= http.StatusOK && status < http.StatusMultipleChoices:
|
||||
return "ok"
|
||||
return CodeSuccess
|
||||
case status == http.StatusBadRequest:
|
||||
return "invalid_request"
|
||||
return CodeInvalidParam
|
||||
case status == http.StatusUnauthorized:
|
||||
return "unauthorized"
|
||||
return CodeUnauthorized
|
||||
case status == http.StatusForbidden:
|
||||
return "forbidden"
|
||||
return CodeForbidden
|
||||
case status == http.StatusNotFound:
|
||||
return "not_found"
|
||||
return CodeResourceNotFound
|
||||
case status == http.StatusConflict:
|
||||
return "conflict"
|
||||
return CodeResourceConflict
|
||||
case status == http.StatusTooManyRequests:
|
||||
return "rate_limited"
|
||||
return CodeRateLimited
|
||||
case status >= http.StatusBadRequest && status < http.StatusInternalServerError:
|
||||
return "request_error"
|
||||
return CodeInvalidParam
|
||||
case status >= http.StatusInternalServerError:
|
||||
return "internal_error"
|
||||
return CodeInternalError
|
||||
default:
|
||||
return "ok"
|
||||
return CodeSuccess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,16 +311,33 @@ func isEnvelopeObject(obj map[string]json.RawMessage) bool {
|
||||
if obj == nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := obj["code"]; !ok {
|
||||
// Check for new envelope format (code is number, has trace_id)
|
||||
codeRaw, hasCode := obj["code"]
|
||||
_, hasData := obj["data"]
|
||||
_, hasMessage := obj["message"]
|
||||
_, hasTraceID := obj["trace_id"]
|
||||
|
||||
if !hasCode || !hasData || !hasMessage {
|
||||
return false
|
||||
}
|
||||
if _, ok := obj["data"]; !ok {
|
||||
return false
|
||||
|
||||
// If has trace_id, it's definitely our envelope
|
||||
if hasTraceID {
|
||||
return true
|
||||
}
|
||||
if _, ok := obj["message"]; !ok {
|
||||
return false
|
||||
|
||||
// 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
|
||||
if json.Unmarshal(codeRaw, &codeNum) == nil {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
var codeStr string
|
||||
if json.Unmarshal(codeRaw, &codeStr) == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func writeStatusOnly(w gin.ResponseWriter, status int) {
|
||||
|
||||
Reference in New Issue
Block a user