diff --git a/epoch/context_keys.go b/epoch/context_keys.go new file mode 100644 index 0000000..03fa700 --- /dev/null +++ b/epoch/context_keys.go @@ -0,0 +1,55 @@ +package epoch + +import ( + "github.com/gin-gonic/gin" +) + +// CapturedFieldsKey is the context key for storing captured request field values +// These are used by response transformers to restore original values when +// a field was removed from the request and needs to be added back to the response +const CapturedFieldsKey = "epoch_captured_fields" + +// GetCapturedFields retrieves the captured fields map from a Gin context +func GetCapturedFields(c *gin.Context) map[string]interface{} { + if c == nil { + return nil + } + if val, exists := c.Get(CapturedFieldsKey); exists { + if fields, ok := val.(map[string]interface{}); ok { + return fields + } + } + return nil +} + +// SetCapturedField stores a captured field value in the Gin context +// The field is keyed by field name only. Since the Gin context is request-scoped, +// there's no risk of collision between different requests. This allows captured +// values to flow from request types to response types with the same field name. +func SetCapturedField(c *gin.Context, fieldName string, value interface{}) { + if c == nil { + return + } + fields := GetCapturedFields(c) + if fields == nil { + fields = make(map[string]interface{}) + c.Set(CapturedFieldsKey, fields) + } + fields[fieldName] = value +} + +// GetCapturedField retrieves a specific captured field value +func GetCapturedField(c *gin.Context, fieldName string) (interface{}, bool) { + fields := GetCapturedFields(c) + if fields == nil { + return nil, false + } + val, exists := fields[fieldName] + return val, exists +} + +// HasCapturedField checks if a field has been captured +func HasCapturedField(c *gin.Context, fieldName string) bool { + _, exists := GetCapturedField(c, fieldName) + return exists +} diff --git a/epoch/integration_test.go b/epoch/integration_test.go index 87edd7e..52ba69f 100644 --- a/epoch/integration_test.go +++ b/epoch/integration_test.go @@ -3,8 +3,10 @@ package epoch import ( "bytes" "encoding/json" + "fmt" "net/http/httptest" "strings" + "sync" "github.com/gin-gonic/gin" . "github.com/onsi/ginkgo/v2" @@ -1934,4 +1936,388 @@ var _ = Describe("End-to-End Integration Tests", func() { Expect(profile1).NotTo(HaveKey("avatar")) }) }) + + Describe("Auto-Capture Field Preservation", func() { + // AutoCaptureRequest is the request type for auto-capture tests + type AutoCaptureRequest struct { + Name string `json:"name"` + Description string `json:"description"` // This field is removed in v2 but we want to preserve the value + Metadata string `json:"metadata"` // Another field to test multiple captures + } + + // AutoCaptureResponse is the response type for auto-capture tests + type AutoCaptureResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` // Added back in response with captured value + Metadata string `json:"metadata"` // Added back in response with captured value + CreatedAt string `json:"created_at"` + } + + It("should preserve field values from request in response when using RemoveField/AddField", func() { + v1, _ := NewDateVersion("2024-01-01") + v2, _ := NewDateVersion("2024-06-01") + + // V1→V2: Remove deprecated fields from request, add them back in response + // The auto-capture feature should preserve the original values + change := NewVersionChangeBuilder(v1, v2). + Description("Remove description and metadata fields"). + ForType(AutoCaptureRequest{}). + RequestToNextVersion(). + RemoveField("description"). // Captured automatically + RemoveField("metadata"). // Captured automatically + ForType(AutoCaptureResponse{}). + ResponseToPreviousVersion(). + AddField("description", "default description"). // Uses captured value instead + AddField("metadata", "default metadata"). // Uses captured value instead + Build() + + epochInstance, err := setupBasicEpoch([]*Version{v1, v2}, []*VersionChange{change}) + Expect(err).NotTo(HaveOccurred()) + + router := setupRouterWithMiddleware(epochInstance) + + // Handler receives HEAD version (without description/metadata) + // and returns HEAD version (without description/metadata) + router.POST("/items", epochInstance.WrapHandler(func(c *gin.Context) { + var req map[string]interface{} + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + // Verify handler does NOT receive the deprecated fields + Expect(req).NotTo(HaveKey("description"), "Handler should not receive removed field") + Expect(req).NotTo(HaveKey("metadata"), "Handler should not receive removed field") + Expect(req).To(HaveKey("name")) + + // Return response without deprecated fields (HEAD version) + c.JSON(200, gin.H{ + "id": 1, + "name": req["name"], + "created_at": "2024-01-15T10:00:00Z", + }) + }).Accepts(AutoCaptureRequest{}).Returns(AutoCaptureResponse{}).ToHandlerFunc("POST", "/items")) + + // V1 client sends request WITH deprecated fields + reqBody := `{ + "name": "Test Item", + "description": "My custom description", + "metadata": "Important metadata value" + }` + req := httptest.NewRequest("POST", "/items", strings.NewReader(reqBody)) + req.Header.Set("X-API-Version", "2024-01-01") + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(200)) + + var response map[string]interface{} + err = json.Unmarshal(recorder.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + + // V1 response should have the ORIGINAL values from the request, NOT defaults + Expect(response).To(HaveKey("id")) + Expect(response).To(HaveKey("name")) + Expect(response).To(HaveKey("description")) + Expect(response).To(HaveKey("metadata")) + Expect(response).To(HaveKey("created_at")) + + // These should be the captured values, not the defaults + Expect(response["description"]).To(Equal("My custom description"), + "Should use captured value from request, not default") + Expect(response["metadata"]).To(Equal("Important metadata value"), + "Should use captured value from request, not default") + }) + + It("should use defaults when request doesn't have the field", func() { + v1, _ := NewDateVersion("2024-01-01") + v2, _ := NewDateVersion("2024-06-01") + + change := NewVersionChangeBuilder(v1, v2). + Description("Handle optional field"). + ForType(AutoCaptureRequest{}). + RequestToNextVersion(). + RemoveField("description"). + ForType(AutoCaptureResponse{}). + ResponseToPreviousVersion(). + AddField("description", "default description"). + Build() + + epochInstance, err := setupBasicEpoch([]*Version{v1, v2}, []*VersionChange{change}) + Expect(err).NotTo(HaveOccurred()) + + router := setupRouterWithMiddleware(epochInstance) + + router.POST("/items", epochInstance.WrapHandler(func(c *gin.Context) { + var req map[string]interface{} + c.ShouldBindJSON(&req) + + c.JSON(200, gin.H{ + "id": 1, + "name": req["name"], + "created_at": "2024-01-15T10:00:00Z", + }) + }).Accepts(AutoCaptureRequest{}).Returns(AutoCaptureResponse{}).ToHandlerFunc("POST", "/items")) + + // V1 client sends request WITHOUT the optional field + reqBody := `{"name": "Test Item"}` + req := httptest.NewRequest("POST", "/items", strings.NewReader(reqBody)) + req.Header.Set("X-API-Version", "2024-01-01") + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(200)) + + var response map[string]interface{} + json.Unmarshal(recorder.Body.Bytes(), &response) + + // Should use default since field wasn't in request + Expect(response["description"]).To(Equal("default description"), + "Should use default when field wasn't in request") + }) + + It("should not override handler-set values with captured values", func() { + v1, _ := NewDateVersion("2024-01-01") + v2, _ := NewDateVersion("2024-06-01") + + change := NewVersionChangeBuilder(v1, v2). + Description("Handler override test"). + ForType(AutoCaptureRequest{}). + RequestToNextVersion(). + RemoveField("description"). + ForType(AutoCaptureResponse{}). + ResponseToPreviousVersion(). + AddField("description", "default description"). + Build() + + epochInstance, err := setupBasicEpoch([]*Version{v1, v2}, []*VersionChange{change}) + Expect(err).NotTo(HaveOccurred()) + + router := setupRouterWithMiddleware(epochInstance) + + router.POST("/items", epochInstance.WrapHandler(func(c *gin.Context) { + var req map[string]interface{} + c.ShouldBindJSON(&req) + + // Handler explicitly sets description in response + c.JSON(200, gin.H{ + "id": 1, + "name": req["name"], + "description": "Handler-set description", // Explicit value + "created_at": "2024-01-15T10:00:00Z", + }) + }).Accepts(AutoCaptureRequest{}).Returns(AutoCaptureResponse{}).ToHandlerFunc("POST", "/items")) + + reqBody := `{ + "name": "Test Item", + "description": "Request description" + }` + req := httptest.NewRequest("POST", "/items", strings.NewReader(reqBody)) + req.Header.Set("X-API-Version", "2024-01-01") + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(200)) + + var response map[string]interface{} + json.Unmarshal(recorder.Body.Bytes(), &response) + + // Handler's value should be preserved, not overwritten by captured value + Expect(response["description"]).To(Equal("Handler-set description"), + "Handler-set value should take precedence") + }) + + It("should preserve captured values for complex types", func() { + // Test type with complex field + type ComplexRequest struct { + Name string `json:"name"` + Settings map[string]interface{} `json:"settings"` + } + + type ComplexResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Settings map[string]interface{} `json:"settings"` + } + + v1, _ := NewDateVersion("2024-01-01") + v2, _ := NewDateVersion("2024-06-01") + + change := NewVersionChangeBuilder(v1, v2). + Description("Complex field preservation"). + ForType(ComplexRequest{}). + RequestToNextVersion(). + RemoveField("settings"). + ForType(ComplexResponse{}). + ResponseToPreviousVersion(). + AddField("settings", map[string]interface{}{"default": true}). + Build() + + epochInstance, err := setupBasicEpoch([]*Version{v1, v2}, []*VersionChange{change}) + Expect(err).NotTo(HaveOccurred()) + + router := setupRouterWithMiddleware(epochInstance) + + router.POST("/complex", epochInstance.WrapHandler(func(c *gin.Context) { + var req map[string]interface{} + c.ShouldBindJSON(&req) + + Expect(req).NotTo(HaveKey("settings")) + + c.JSON(200, gin.H{ + "id": 1, + "name": req["name"], + }) + }).Accepts(ComplexRequest{}).Returns(ComplexResponse{}).ToHandlerFunc("POST", "/complex")) + + reqBody := `{ + "name": "Test", + "settings": { + "theme": "dark", + "notifications": true, + "nested": {"key": "value"} + } + }` + req := httptest.NewRequest("POST", "/complex", strings.NewReader(reqBody)) + req.Header.Set("X-API-Version", "2024-01-01") + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(200)) + + var response map[string]interface{} + json.Unmarshal(recorder.Body.Bytes(), &response) + + // Complex object should be preserved + settings, ok := response["settings"].(map[string]interface{}) + Expect(ok).To(BeTrue(), "settings should be a map") + Expect(settings["theme"]).To(Equal("dark")) + Expect(settings["notifications"]).To(Equal(true)) + + nested, ok := settings["nested"].(map[string]interface{}) + Expect(ok).To(BeTrue(), "nested should be a map") + Expect(nested["key"]).To(Equal("value")) + }) + + It("should be thread-safe with concurrent requests", func() { + // This test verifies that captured field values don't leak between concurrent requests + type ConcurrentRequest struct { + ID int `json:"id"` + Description string `json:"description"` + } + + type ConcurrentResponse struct { + ID int `json:"id"` + Description string `json:"description"` + ProcessedAt string `json:"processed_at"` + } + + v1, _ := NewDateVersion("2024-01-01") + v2, _ := NewDateVersion("2024-06-01") + + change := NewVersionChangeBuilder(v1, v2). + Description("Concurrent test"). + ForType(ConcurrentRequest{}). + RequestToNextVersion(). + RemoveField("description"). + ForType(ConcurrentResponse{}). + ResponseToPreviousVersion(). + AddField("description", "default"). + Build() + + epochInstance, err := setupBasicEpoch([]*Version{v1, v2}, []*VersionChange{change}) + Expect(err).NotTo(HaveOccurred()) + + router := setupRouterWithMiddleware(epochInstance) + + router.POST("/concurrent", epochInstance.WrapHandler(func(c *gin.Context) { + var req map[string]interface{} + c.ShouldBindJSON(&req) + + // Handler returns the ID it received (without description) + c.JSON(200, gin.H{ + "id": req["id"], + "processed_at": "2024-01-15T10:00:00Z", + }) + }).Accepts(ConcurrentRequest{}).Returns(ConcurrentResponse{}).ToHandlerFunc("POST", "/concurrent")) + + // Run concurrent requests + const numRequests = 50 + var wg sync.WaitGroup + results := make(chan struct { + requestID int + description string + err error + }, numRequests) + + for i := 0; i < numRequests; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + + // Each request has a unique description + description := fmt.Sprintf("Description for request %d", id) + reqBody := fmt.Sprintf(`{"id": %d, "description": "%s"}`, id, description) + + req := httptest.NewRequest("POST", "/concurrent", strings.NewReader(reqBody)) + req.Header.Set("X-API-Version", "2024-01-01") + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + if recorder.Code != 200 { + results <- struct { + requestID int + description string + err error + }{id, "", fmt.Errorf("unexpected status code: %d", recorder.Code)} + return + } + + var response map[string]interface{} + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + results <- struct { + requestID int + description string + err error + }{id, "", err} + return + } + + results <- struct { + requestID int + description string + err error + }{ + requestID: int(response["id"].(float64)), + description: response["description"].(string), + err: nil, + } + }(i) + } + + wg.Wait() + close(results) + + // Verify all requests got their own captured values back + for result := range results { + Expect(result.err).NotTo(HaveOccurred(), fmt.Sprintf("Request %d failed", result.requestID)) + + expectedDescription := fmt.Sprintf("Description for request %d", result.requestID) + Expect(result.description).To(Equal(expectedDescription), + fmt.Sprintf("Request %d got wrong description: expected '%s', got '%s'", + result.requestID, expectedDescription, result.description)) + } + }) + }) }) diff --git a/epoch/version_change_builder.go b/epoch/version_change_builder.go index ad0fa76..aed734b 100644 --- a/epoch/version_change_builder.go +++ b/epoch/version_change_builder.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/bytedance/sonic/ast" + "github.com/gin-gonic/gin" ) // ============================================================================ @@ -127,6 +128,20 @@ func (b *versionChangeBuilder) Build() *VersionChange { return nil } + // Before applying RemoveField operations, capture the field values + for _, op := range requestOpsCopy { + if removeOp, ok := op.(*RequestRemoveField); ok { + fieldNode := req.Body.Get(removeOp.Name) + if fieldNode != nil && fieldNode.Exists() { + // Capture the field value before removal + value, err := fieldNode.Interface() + if err == nil && req.GinContext != nil { + SetCapturedField(req.GinContext, removeOp.Name, value) + } + } + } + } + // Request migration is always FROM client version TO HEAD version // Apply "to next version" operations (Client→HEAD) return requestOpsCopy.Apply(req.Body) @@ -150,6 +165,9 @@ func (b *versionChangeBuilder) Build() *VersionChange { if resp.Body.TypeSafe() == ast.V_ARRAY { // For arrays, apply operations to each item if err := resp.TransformArrayField("", func(node *ast.Node) error { + // Pre-populate captured values before AddField operations + restoreCapturedFieldsToNode(resp.GinContext, targetTypeCopy, responseOpsCopy, node) + // Response migration is always FROM HEAD version TO client version // Apply "to previous version" operations (HEAD→Client) return responseOpsCopy.Apply(node) @@ -157,6 +175,9 @@ func (b *versionChangeBuilder) Build() *VersionChange { return err } } else { + // Pre-populate captured values before AddField operations + restoreCapturedFieldsToNode(resp.GinContext, targetTypeCopy, responseOpsCopy, resp.Body) + // For objects, apply operations to the object // Response migration is always FROM HEAD version TO client version if err := responseOpsCopy.Apply(resp.Body); err != nil { @@ -389,9 +410,38 @@ func (b *responseToPreviousVersionBuilder) Build() *VersionChange { return b.parent.Build() } -// ============================================================================ -// ERROR FIELD NAME TRANSFORMATION HELPERS -// ============================================================================ +// restoreCapturedFieldsToNode pre-populates captured request field values into the response node +// before AddField operations run. Since AddField skips existing fields, this effectively +// restores the original request values instead of using hardcoded defaults. +// This is called for each node (root object or array items) during response transformation. +func restoreCapturedFieldsToNode( + ctx *gin.Context, + targetType reflect.Type, + responseOps ResponseToPreviousVersionOperationList, + node *ast.Node, +) { + if ctx == nil || node == nil { + return + } + + // For each AddField operation, check if we have a captured value + for _, op := range responseOps { + if addOp, ok := op.(*ResponseAddField); ok { + // Only restore if the field doesn't already exist in the response + // (handler may have explicitly set it) + if node.Get(addOp.Name).Exists() { + continue + } + + // Check for a captured value from request migration + if capturedValue, exists := GetCapturedField(ctx, addOp.Name); exists { + // Pre-populate the field with captured value + // AddField will then skip this field since it exists + _ = SetNodeField(node, addOp.Name, capturedValue) + } + } + } +} // transformErrorFieldNamesInResponse transforms field names in error messages // Works with any error response format by recursively processing all string fields diff --git a/epoch/version_change_builder_test.go b/epoch/version_change_builder_test.go index 35ae1ca..b2dc62b 100644 --- a/epoch/version_change_builder_test.go +++ b/epoch/version_change_builder_test.go @@ -1,8 +1,12 @@ package epoch import ( + "net/http/httptest" + "reflect" + "github.com/bytedance/sonic" "github.com/bytedance/sonic/ast" + "github.com/gin-gonic/gin" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -630,4 +634,163 @@ var _ = Describe("SchemaVersionChangeBuilder", func() { }) }) }) + + Describe("Auto-Capture Field Behavior", func() { + Describe("Context Helpers", func() { + var ginContext *gin.Context + + BeforeEach(func() { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ginContext, _ = gin.CreateTestContext(w) + }) + + It("should store and retrieve captured field values", func() { + SetCapturedField(ginContext, "email", "test@example.com") + + value, exists := GetCapturedField(ginContext, "email") + Expect(exists).To(BeTrue()) + Expect(value).To(Equal("test@example.com")) + }) + + It("should return false for non-existent captured fields", func() { + _, exists := GetCapturedField(ginContext, "nonexistent") + Expect(exists).To(BeFalse()) + }) + + It("should allow same field name to flow from request to response types", func() { + // This is the key use case: RemoveField on RequestType captures value, + // AddField on ResponseType restores it - they share the same field name key + SetCapturedField(ginContext, "description", "User description") + + // Later, response transformer can retrieve it + value, exists := GetCapturedField(ginContext, "description") + Expect(exists).To(BeTrue()) + Expect(value).To(Equal("User description")) + }) + + It("should handle complex types as values", func() { + complexValue := map[string]interface{}{ + "nested": "value", + "count": float64(42), + } + SetCapturedField(ginContext, "metadata", complexValue) + + value, exists := GetCapturedField(ginContext, "metadata") + Expect(exists).To(BeTrue()) + Expect(value).To(HaveKeyWithValue("nested", "value")) + Expect(value).To(HaveKeyWithValue("count", float64(42))) + }) + + It("should handle nil context gracefully", func() { + // Should not panic + SetCapturedField(nil, "field", "value") + + value, exists := GetCapturedField(nil, "field") + Expect(exists).To(BeFalse()) + Expect(value).To(BeNil()) + }) + + It("should check field existence with HasCapturedField", func() { + SetCapturedField(ginContext, "email", "test@example.com") + + Expect(HasCapturedField(ginContext, "email")).To(BeTrue()) + Expect(HasCapturedField(ginContext, "other")).To(BeFalse()) + }) + }) + + Describe("restoreCapturedFieldsToNode", func() { + var ginContext *gin.Context + + BeforeEach(func() { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ginContext, _ = gin.CreateTestContext(w) + }) + + It("should restore captured values for AddField operations", func() { + targetType := reflect.TypeOf(BuilderTestUser{}) + + // Simulate captured value from request + SetCapturedField(ginContext, "email", "captured@example.com") + + // Response operations that include AddField for email + responseOps := ResponseToPreviousVersionOperationList{ + &ResponseAddField{Name: "email", Default: "default@example.com"}, + } + + // Create a response node without the email field + node, _ := sonic.Get([]byte(`{"id": 1, "name": "Test"}`)) + _ = node.Load() + + // Restore captured fields + restoreCapturedFieldsToNode(ginContext, targetType, responseOps, &node) + + // Email should now be populated with captured value + emailNode := node.Get("email") + Expect(emailNode.Exists()).To(BeTrue()) + email, _ := emailNode.String() + Expect(email).To(Equal("captured@example.com")) + }) + + It("should not override existing fields in response", func() { + targetType := reflect.TypeOf(BuilderTestUser{}) + + // Simulate captured value + SetCapturedField(ginContext, "email", "captured@example.com") + + responseOps := ResponseToPreviousVersionOperationList{ + &ResponseAddField{Name: "email", Default: "default@example.com"}, + } + + // Response already has email from handler + node, _ := sonic.Get([]byte(`{"id": 1, "email": "handler@example.com"}`)) + _ = node.Load() + + restoreCapturedFieldsToNode(ginContext, targetType, responseOps, &node) + + // Should keep handler's value, not captured or default + email, _ := node.Get("email").String() + Expect(email).To(Equal("handler@example.com")) + }) + + It("should handle nil context gracefully", func() { + targetType := reflect.TypeOf(BuilderTestUser{}) + responseOps := ResponseToPreviousVersionOperationList{ + &ResponseAddField{Name: "email", Default: "default@example.com"}, + } + + node, _ := sonic.Get([]byte(`{"id": 1}`)) + _ = node.Load() + + // Should not panic with nil context + restoreCapturedFieldsToNode(nil, targetType, responseOps, &node) + + // Email should not be added (no context to get captured value from) + Expect(node.Get("email").Exists()).To(BeFalse()) + }) + + It("should restore multiple captured fields", func() { + targetType := reflect.TypeOf(BuilderTestUser{}) + + SetCapturedField(ginContext, "email", "test@example.com") + SetCapturedField(ginContext, "phone", "+1-555-0100") + + responseOps := ResponseToPreviousVersionOperationList{ + &ResponseAddField{Name: "email", Default: "default@example.com"}, + &ResponseAddField{Name: "phone", Default: "000-000-0000"}, + } + + node, _ := sonic.Get([]byte(`{"id": 1}`)) + _ = node.Load() + + restoreCapturedFieldsToNode(ginContext, targetType, responseOps, &node) + + email, _ := node.Get("email").String() + phone, _ := node.Get("phone").String() + Expect(email).To(Equal("test@example.com")) + Expect(phone).To(Equal("+1-555-0100")) + }) + }) + }) }) diff --git a/examples/advanced/main.go b/examples/advanced/main.go index c683e9f..73ecf83 100644 --- a/examples/advanced/main.go +++ b/examples/advanced/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "net/http" + "sync" "time" "github.com/astronomer/epoch/epoch" @@ -38,24 +39,26 @@ type ProfileRequest struct { } // CreateUserRequest - What clients send to create a user (HEAD version) -// v1 (2024-01-01): name, profile.biography, profile.skills[].skill_name -// v2 (2024-06-01): name, email, status, profile.bio, profile.skills[].name + level +// v1 (2024-01-01): name, legacy_notes, profile.biography, profile.skills[].skill_name +// v2 (2024-06-01): name, email, status, profile.bio, profile.skills[].name + level (legacy_notes removed but auto-captured) // v3 (2025-01-01): full_name, email, phone, status, profile (all fields) type CreateUserRequest struct { - FullName string `json:"full_name" binding:"required,max=100"` - Email string `json:"email" binding:"required,email"` - Phone string `json:"phone,omitempty"` - Status string `json:"status" binding:"required,oneof=active inactive pending suspended"` - Profile *ProfileRequest `json:"profile,omitempty"` // Nested object with array and deeply nested settings + FullName string `json:"full_name" binding:"required,max=100"` + Email string `json:"email" binding:"required,email"` + Phone string `json:"phone,omitempty"` + Status string `json:"status" binding:"required,oneof=active inactive pending suspended"` + Profile *ProfileRequest `json:"profile,omitempty"` // Nested object with array and deeply nested settings + LegacyNotes string `json:"legacy_notes,omitempty"` // Deprecated in v2+, auto-captured for round-trip preservation } // UpdateUserRequest - What clients send to update a user (HEAD version) type UpdateUserRequest struct { - FullName string `json:"full_name" binding:"required,max=100"` - Email string `json:"email" binding:"required,email"` - Phone string `json:"phone,omitempty"` - Status string `json:"status" binding:"required,oneof=active inactive pending suspended"` - Profile *ProfileRequest `json:"profile,omitempty"` // Nested object with array and deeply nested settings + FullName string `json:"full_name" binding:"required,max=100"` + Email string `json:"email" binding:"required,email"` + Phone string `json:"phone,omitempty"` + Status string `json:"status" binding:"required,oneof=active inactive pending suspended"` + Profile *ProfileRequest `json:"profile,omitempty"` // Nested object with array and deeply nested settings + LegacyNotes string `json:"legacy_notes,omitempty"` // Deprecated in v2+, auto-captured } // ============================================================================ @@ -91,12 +94,13 @@ type UserProfile struct { // Migrations handle transforming this to v1/v2/v3 formats // Now includes Profile for nested transformation demonstrations type UserResponse struct { - ID int `json:"id,omitempty"` - FullName string `json:"full_name"` - Email string `json:"email,omitempty"` - Phone string `json:"phone,omitempty"` - Status string `json:"status,omitempty"` - Profile *UserProfile `json:"profile,omitempty"` // Nested object with array and deeply nested settings + ID int `json:"id,omitempty"` + FullName string `json:"full_name"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + Status string `json:"status,omitempty"` + Profile *UserProfile `json:"profile,omitempty"` // Nested object with array and deeply nested settings + LegacyNotes string `json:"legacy_notes,omitempty"` // Auto-captured from request for v1 clients } type UsersListResponse struct { @@ -123,12 +127,13 @@ type UserProfileInternal struct { } type UserInternal struct { - ID int - FullName string - Email string - Phone string - Status string - Profile *UserProfileInternal + ID int + FullName string + Email string + Phone string + Status string + Profile *UserProfileInternal + LegacyNotes string // For auto-capture demo - stored but not used by v2+ handlers } // ============================================================================ @@ -281,11 +286,12 @@ type ExampleMetaInternal struct { // User conversions func NewUserResponse(u UserInternal) UserResponse { resp := UserResponse{ - ID: u.ID, - FullName: u.FullName, - Email: u.Email, - Phone: u.Phone, - Status: u.Status, + ID: u.ID, + FullName: u.FullName, + Email: u.Email, + Phone: u.Phone, + Status: u.Status, + LegacyNotes: u.LegacyNotes, // Include for auto-capture demo } if u.Profile != nil { skills := make([]Skill, len(u.Profile.Skills)) @@ -389,6 +395,7 @@ var ( users = map[int]UserInternal{ 1: { ID: 1, FullName: "Alice Johnson", Email: "alice@example.com", Phone: "+1-555-0100", Status: "active", + LegacyNotes: "Original v1 user - migrated from legacy system", // Demo: stored legacy notes Profile: &UserProfileInternal{ Bio: "Senior software engineer with 10 years of experience", Skills: []SkillInternal{ @@ -449,6 +456,11 @@ var ( nextUserID = 3 nextProductID = 3 nextOrderID = 2 + + // Mutexes for thread-safe access to in-memory storage + usersMu sync.RWMutex + productsMu sync.RWMutex + ordersMu sync.RWMutex ) func main() { @@ -761,6 +773,31 @@ func main() { fmt.Println(" # \"skills\":[{\"skill_name\":\"JavaScript\"},{\"skill_name\":\"React\"}],") fmt.Println(" # \"settings\":{\"color_theme\":\"light\"}}}") fmt.Println("") + fmt.Println("🔄 16. AUTO-CAPTURE FIELD PRESERVATION (legacy_notes)") + fmt.Println(" # NEW FEATURE: Deprecated fields are automatically preserved from request to response!") + fmt.Println(" # V1 sends legacy_notes -> captured before removal -> restored in response") + fmt.Println("") + fmt.Println(" # Test 1: V1 POST with legacy_notes - value PRESERVED in response") + fmt.Println(" curl -X POST -H 'X-API-Version: 2024-01-01' -H 'Content-Type: application/json' \\") + fmt.Println(" -d '{\"name\":\"Auto Capture Test\",\"legacy_notes\":\"Important notes from v1 client\"}' \\") + fmt.Println(" http://localhost:8090/users") + fmt.Println(" # Expected: {...,\"legacy_notes\":\"Important notes from v1 client\"}") + fmt.Println(" # The value is NOT empty - it's the ORIGINAL value from the request!") + fmt.Println("") + fmt.Println(" # Test 2: V1 POST without legacy_notes - uses default empty string") + fmt.Println(" curl -X POST -H 'X-API-Version: 2024-01-01' -H 'Content-Type: application/json' \\") + fmt.Println(" -d '{\"name\":\"No Notes Test\"}' \\") + fmt.Println(" http://localhost:8090/users") + fmt.Println(" # Expected: {...,\"legacy_notes\":\"\"} (default value since not in request)") + fmt.Println("") + fmt.Println(" # Test 3: V1 GET existing user with stored legacy_notes") + fmt.Println(" curl -H 'X-API-Version: 2024-01-01' http://localhost:8090/users/1") + fmt.Println(" # Expected: {...,\"legacy_notes\":\"Original v1 user - migrated from legacy system\"}") + fmt.Println("") + fmt.Println(" # Test 4: V2 sees legacy_notes pass through (Cadwyn-style, schema ignores it)") + fmt.Println(" curl -H 'X-API-Version: 2024-06-01' http://localhost:8090/users/1") + fmt.Println(" # Expected: legacy_notes passes through (V2 schema ignores it, but data is there)") + fmt.Println("") fmt.Println("🌐 Server listening on http://localhost:8090") fmt.Println(" Use X-API-Version header to specify version") fmt.Println("") @@ -778,20 +815,26 @@ func main() { // createUserV1ToV2Migration defines migrations for TOP-LEVEL user fields only // Nested types (Profile, Skill, Settings) have their own separate migrations +// +// IMPORTANT: This migration demonstrates the AUTO-CAPTURE feature: +// - RemoveField("legacy_notes") on request CAPTURES the value before removing +// - AddField("legacy_notes", "") on response USES the captured value instead of the default +// This enables seamless round-trip preservation of deprecated fields! func createUserV1ToV2Migration(from, to *epoch.Version) *epoch.VersionChange { return epoch.NewVersionChangeBuilder(from, to). - Description("Add email and status fields for v1->v2"). + Description("Add email and status fields, deprecate legacy_notes (auto-captured)"). // Only target top-level user types (NOT nested types - they have separate migrations) ForType(UserResponse{}, CreateUserRequest{}, UpdateUserRequest{}). - // Requests: Client→HEAD (add defaults for old clients) + // Requests: Client→HEAD (add defaults for old clients, capture legacy_notes) RequestToNextVersion(). AddField("email", "unknown@example.com"). // Add email with default for v1 clients AddField("status", "active"). // Add status with default for v1 clients - RemoveField("temp_field"). // Remove deprecated field - // Responses: HEAD→Client (remove new fields for old clients) + RemoveField("legacy_notes"). // AUTO-CAPTURE: Captures value before removing! + // Responses: HEAD→Client (remove new fields, restore legacy_notes) ResponseToPreviousVersion(). - RemoveField("email"). // Remove email from responses for v1 clients - RemoveField("status"). // Remove status from responses for v1 clients + RemoveField("email"). // Remove email from responses for v1 clients + RemoveField("status"). // Remove status from responses for v1 clients + AddField("legacy_notes", ""). // AUTO-CAPTURE: Uses captured value from request, not ""! Build() } @@ -856,6 +899,7 @@ func createUserV2ToV3Migration(from, to *epoch.Version) *epoch.VersionChange { ResponseToPreviousVersion(). RenameField("full_name", "name"). // Rename back to old field name RemoveField("phone"). // Remove phone from responses for v2 clients + // NOTE: legacy_notes passes through (Cadwyn-style) - V2 schema ignores it Build() } @@ -912,6 +956,9 @@ func createExampleV2ToV3Migration(from, to *epoch.Version) *epoch.VersionChange // ============================================================================ func listUsers(c *gin.Context) { + usersMu.RLock() + defer usersMu.RUnlock() + // Convert internal storage to list of internal models userList := make([]UserInternal, 0, len(users)) for _, user := range users { @@ -927,7 +974,10 @@ func getUser(c *gin.Context) { var userID int fmt.Sscanf(id, "%d", &userID) + usersMu.RLock() user, exists := users[userID] + usersMu.RUnlock() + if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return @@ -945,13 +995,17 @@ func createUser(c *gin.Context) { return } + usersMu.Lock() + defer usersMu.Unlock() + // Convert to internal model (including nested profile if provided) internal := UserInternal{ - ID: nextUserID, - FullName: req.FullName, - Email: req.Email, - Phone: req.Phone, - Status: req.Status, + ID: nextUserID, + FullName: req.FullName, + Email: req.Email, + Phone: req.Phone, + Status: req.Status, + LegacyNotes: req.LegacyNotes, // Will be empty for v2+ (auto-capture handles v1) } // Handle nested profile from request (demonstrates request nested transformation) @@ -974,6 +1028,7 @@ func createUser(c *gin.Context) { // Always return HEAD version response struct // Epoch middleware will transform it to the client's requested version + // For v1 clients: auto-capture will restore legacy_notes from the request! c.JSON(http.StatusCreated, NewUserResponse(internal)) } @@ -982,6 +1037,9 @@ func updateUser(c *gin.Context) { var userID int fmt.Sscanf(id, "%d", &userID) + usersMu.Lock() + defer usersMu.Unlock() + _, exists := users[userID] if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) @@ -997,11 +1055,12 @@ func updateUser(c *gin.Context) { // Convert to internal model (including nested profile if provided) internal := UserInternal{ - ID: userID, - FullName: req.FullName, - Email: req.Email, - Phone: req.Phone, - Status: req.Status, + ID: userID, + FullName: req.FullName, + Email: req.Email, + Phone: req.Phone, + Status: req.Status, + LegacyNotes: req.LegacyNotes, // Auto-capture handles v1 round-trip } // Handle nested profile from request (demonstrates request nested transformation)