Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions epoch/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,14 +361,23 @@ func (vah *VersionAwareHandler) handleWithMigration(c *gin.Context, requestedVer
vah.handler(c)

// 4. Migrate response using KNOWN type(s)
if endpointDef.ResponseType != nil {
// Always attempt migration for error responses (status >= 400) to transform field names
// even if no response type is registered. For error responses, use request type if available
// since validation errors reference request field names.
responseTypeForMigration := endpointDef.ResponseType
if responseTypeForMigration == nil && responseCapture.statusCode >= 400 && endpointDef.RequestType != nil {
// Use request type for error transformation
responseTypeForMigration = endpointDef.RequestType
}

if responseTypeForMigration != nil || responseCapture.statusCode >= 400 {
if err := vah.migrateResponse(c, requestedVersion, responseCapture,
endpointDef.ResponseType, endpointDef.NestedArrays); err != nil {
responseTypeForMigration, endpointDef.NestedArrays); err != nil {
c.JSON(500, gin.H{"error": "Response migration failed", "details": err.Error()})
return
}
} else {
// No response type registered, write response as-is
// No response type registered and not an error, write response as-is
c.Writer = responseCapture.ResponseWriter
if responseCapture.body != nil {
c.Data(responseCapture.statusCode, "application/json", responseCapture.body)
Expand Down
221 changes: 221 additions & 0 deletions epoch/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package epoch
import (
"encoding/json"
"net/http/httptest"
"strings"
"sync"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -925,4 +926,224 @@ var _ = Describe("Middleware", func() {
Expect(response["version"]).To(Equal("2.0.0")) // Header takes priority
})
})

Describe("Error Field Name Transformation", func() {
var (
e *Epoch
router *gin.Engine
)

// Test request and response types
type TestRequest struct {
BetterNewName string `json:"better_new_name" binding:"required"`
OtherField string `json:"other_field"`
}

type TestResponse struct {
ID int `json:"id"`
BetterNewName string `json:"better_new_name"`
OtherField string `json:"other_field"`
}

BeforeEach(func() {
// Setup versions: v1 → v2 → v3 (HEAD)
v1, _ := NewSemverVersion("1.0.0")
v2, _ := NewSemverVersion("2.0.0")
v3, _ := NewSemverVersion("3.0.0")

// Create migrations with field renames
v1ToV2 := NewVersionChangeBuilder(v1, v2).
Description("Rename name to newName").
ForType(TestRequest{}, TestResponse{}).
RequestToNextVersion().
RenameField("name", "new_name").
ResponseToPreviousVersion().
RenameField("new_name", "name").
Build()

v2ToV3 := NewVersionChangeBuilder(v2, v3).
Description("Rename newName to betterNewName").
ForType(TestRequest{}, TestResponse{}).
RequestToNextVersion().
RenameField("new_name", "better_new_name").
ResponseToPreviousVersion().
RenameField("better_new_name", "new_name").
Build()

var err error
e, err = NewEpoch().
WithVersions(v1, v2, v3).
WithHeadVersion().
WithChanges(v1ToV2, v2ToV3).
WithVersionParameter("X-API-Version").
Build()
Expect(err).NotTo(HaveOccurred())

gin.SetMode(gin.TestMode)
router = gin.New()
router.Use(e.Middleware())
})

Context("Validation Error Transformation", func() {
It("should transform field names in Gin validation errors for v1 clients", func() {
router.POST("/test", e.WrapHandler(func(c *gin.Context) {
var req TestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "success"})
}).Accepts(TestRequest{}).ToHandlerFunc())

w := httptest.NewRecorder()
reqBody := strings.NewReader("{}")
req := httptest.NewRequest("POST", "/test", reqBody)
req.Header.Set("X-API-Version", "1.0.0")
req.Header.Set("Content-Type", "application/json")

router.ServeHTTP(w, req)

Expect(w.Code).To(Equal(400))
body := w.Body.String()

Expect(body).To(ContainSubstring("Name"))
Expect(body).NotTo(ContainSubstring("BetterNewName"))
})

It("should transform field names for v2 clients", func() {
router.POST("/test", e.WrapHandler(func(c *gin.Context) {
var req TestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "success"})
}).Accepts(TestRequest{}).ToHandlerFunc())

w := httptest.NewRecorder()
reqBody := strings.NewReader("{}")
req := httptest.NewRequest("POST", "/test", reqBody)
req.Header.Set("X-API-Version", "2.0.0")
req.Header.Set("Content-Type", "application/json")

router.ServeHTTP(w, req)

Expect(w.Code).To(Equal(400))
body := w.Body.String()

Expect(body).To(ContainSubstring("NewName"))
Expect(body).NotTo(ContainSubstring("BetterNewName"))
})

It("should not transform for HEAD version clients", func() {
router.POST("/test", e.WrapHandler(func(c *gin.Context) {
var req TestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "success"})
}).Accepts(TestRequest{}).ToHandlerFunc())

w := httptest.NewRecorder()
reqBody := strings.NewReader("{}")
req := httptest.NewRequest("POST", "/test", reqBody)
req.Header.Set("X-API-Version", "3.0.0")
req.Header.Set("Content-Type", "application/json")

router.ServeHTTP(w, req)

Expect(w.Code).To(Equal(400))
body := w.Body.String()

Expect(body).To(ContainSubstring("BetterNewName"))
})
})

Context("Custom Error Message Transformation", func() {
It("should transform field names in custom string errors", func() {
router.POST("/test", e.WrapHandler(func(c *gin.Context) {
c.JSON(400, gin.H{
"error": "Missing fields: better_new_name",
})
}).Accepts(TestRequest{}).ToHandlerFunc())

w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/test", nil)
req.Header.Set("X-API-Version", "1.0.0")
req.Header.Set("Content-Type", "application/json")

router.ServeHTTP(w, req)

Expect(w.Code).To(Equal(400))

var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())

errorMsg, ok := response["error"].(string)
Expect(ok).To(BeTrue())
Expect(errorMsg).To(ContainSubstring("name"))
Expect(errorMsg).NotTo(ContainSubstring("better_new_name"))
})

It("should transform field names in structured error objects", func() {
router.POST("/test", e.WrapHandler(func(c *gin.Context) {
c.JSON(400, gin.H{
"error": map[string]interface{}{
"message": "Validation failed for field: better_new_name",
"code": "VALIDATION_ERROR",
},
})
}).Accepts(TestRequest{}).ToHandlerFunc())

w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/test", nil)
req.Header.Set("X-API-Version", "2.0.0")
req.Header.Set("Content-Type", "application/json")

router.ServeHTTP(w, req)

Expect(w.Code).To(Equal(400))

var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())

errorObj, ok := response["error"].(map[string]interface{})
Expect(ok).To(BeTrue())

message, ok := errorObj["message"].(string)
Expect(ok).To(BeTrue())
Expect(message).To(ContainSubstring("new_name"))
Expect(message).NotTo(ContainSubstring("better_new_name"))
})
})

Context("Multi-step Migration", func() {
It("should transform field names at each migration step", func() {
router.POST("/test", e.WrapHandler(func(c *gin.Context) {
var req TestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"message": "success"})
}).Accepts(TestRequest{}).ToHandlerFunc())

w := httptest.NewRecorder()
reqBody := strings.NewReader("{}")
req := httptest.NewRequest("POST", "/test", reqBody)
req.Header.Set("X-API-Version", "1.0.0")
req.Header.Set("Content-Type", "application/json")

router.ServeHTTP(w, req)

body := w.Body.String()

Expect(body).NotTo(ContainSubstring("BetterNewName"))
Expect(body).To(ContainSubstring("Name"))
})
})
})
})
20 changes: 16 additions & 4 deletions examples/advanced/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,11 +485,23 @@ func main() {
fmt.Println(" curl -H 'X-API-Version: 2025-01-01' http://localhost:8085/products/1")
fmt.Println(" # Expected: {\"id\":1,\"name\":\"Laptop\",\"price\":999.99,\"description\":\"High-performance laptop\",\"currency\":\"USD\"}")
fmt.Println("")
fmt.Println("⚠️ 5. ERROR MESSAGE FIELD NAME TRANSFORMATION")
fmt.Println(" # V2 validation error: Shows 'name' in error (not 'full_name')")
fmt.Println("⚠️ 5. ERROR MESSAGE FIELD NAME TRANSFORMATION (NEW!)")
fmt.Println(" Validation errors show field names matching the client's API version")
fmt.Println("")
fmt.Println(" # V1 API - Missing required 'name' field")
fmt.Println(" curl -X POST -H 'X-API-Version: 2024-01-01' -H 'Content-Type: application/json' \\")
fmt.Println(" -d '{}' http://localhost:8085/users")
fmt.Println(" # Expected: Error mentions 'Name' field (v1 field name)")
fmt.Println("")
fmt.Println(" # V2 API - Missing required 'name' field")
fmt.Println(" curl -X POST -H 'X-API-Version: 2024-06-01' -H 'Content-Type: application/json' \\")
fmt.Println(" -d '{\"name\":\"Invalid User\"}' http://localhost:8085/users")
fmt.Println(" # Expected: Error mentions 'Email' and 'Status' fields (not internal field names)")
fmt.Println(" -d '{}' http://localhost:8085/users")
fmt.Println(" # Expected: Error mentions 'Name' field (v2 still uses 'name', not 'full_name')")
fmt.Println("")
fmt.Println(" # V3 API - Missing required 'full_name' field")
fmt.Println(" curl -X POST -H 'X-API-Version: 2025-01-01' -H 'Content-Type: application/json' \\")
fmt.Println(" -d '{}' http://localhost:8085/users")
fmt.Println(" # Expected: Error mentions 'FullName' field (v3 HEAD version)")
fmt.Println("")
fmt.Println("📊 6. LIST ENDPOINTS (Array Transformations)")
fmt.Println(" # V1 user list: Only id + name for each user")
Expand Down