Skip to content

Commit b688f39

Browse files
authored
fix: request and response migrations (#24)
1 parent 033b56f commit b688f39

File tree

9 files changed

+178
-124
lines changed

9 files changed

+178
-124
lines changed

README.md

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,13 @@ change := cadwyn.NewVersionChange(
176176

177177
Cadwyn automatically detects versions from:
178178
- **Headers** (default): `X-API-Version: 2024-01-01`
179-
- **Query params**: `?version=2024-01-01`
180-
- **URL path**: `/v2024-01-01/users`
179+
- **URL path**: `/v2024-01-01/users` or `/2024-01-01/users`
181180

182181
Configure the detection method:
183182
```go
184183
cadwyn.NewCadwyn().
185-
WithVersionLocation(cadwyn.VersionLocationHeader). // or Query, Path
186-
WithVersionParameter("X-API-Version"). // Custom header/param name
184+
WithVersionLocation(cadwyn.VersionLocationHeader). // or Path
185+
WithVersionParameter("X-API-Version"). // Custom header name
187186
Build()
188187
```
189188

@@ -241,7 +240,7 @@ Demonstrates:
241240
## How It Works
242241

243242
1. **You write handlers for the HEAD (latest) version only**
244-
2. **Cadwyn middleware detects the requested API version** from headers/query/path
243+
2. **Cadwyn middleware detects the requested API version** from headers or URL path
245244
3. **Request migration**: Transforms incoming request from old → new format
246245
4. **Your handler executes** with the migrated request
247246
5. **Response migration**: Transforms outgoing response from new → old format
@@ -253,6 +252,67 @@ Client (v1) → [v1 Request] → Migration → [v2 Request] → Handler (v2)
253252
Client (v1) ← [v1 Response] ← Migration ← [v2 Response] ← Handler (v2)
254253
```
255254

255+
## Best Practices
256+
257+
### One VersionChange Per Schema
258+
259+
Create separate `VersionChange` objects for different models/schemas. All migrations in a single `VersionChange` are applied together, so mixing multiple schemas can cause unwanted side effects.
260+
261+
**✅ Good:**
262+
```go
263+
// Separate changes for different schemas
264+
userChange := cadwyn.NewVersionChange(
265+
"Add email to User",
266+
v1, v2,
267+
&cadwyn.AlterResponseInstruction{
268+
Schemas: []interface{}{User{}},
269+
Transformer: func(resp *cadwyn.ResponseInfo) error {
270+
// Only transforms User responses
271+
if userMap, ok := resp.Body.(map[string]interface{}); ok {
272+
delete(userMap, "email")
273+
}
274+
return nil
275+
},
276+
},
277+
)
278+
279+
productChange := cadwyn.NewVersionChange(
280+
"Add SKU to Product",
281+
v1, v2,
282+
&cadwyn.AlterResponseInstruction{
283+
Schemas: []interface{}{Product{}},
284+
Transformer: func(resp *cadwyn.ResponseInfo) error {
285+
// Only transforms Product responses
286+
if productMap, ok := resp.Body.(map[string]interface{}); ok {
287+
delete(productMap, "sku")
288+
}
289+
return nil
290+
},
291+
},
292+
)
293+
294+
cadwyn.NewCadwyn().
295+
WithChanges(userChange, productChange).
296+
Build()
297+
```
298+
299+
**❌ Avoid:**
300+
```go
301+
// Multiple schemas in one VersionChange - transformers run on ALL responses!
302+
change := cadwyn.NewVersionChange(
303+
"Multiple changes",
304+
v1, v2,
305+
&cadwyn.AlterResponseInstruction{
306+
Schemas: []interface{}{User{}},
307+
Transformer: func(resp) { /* runs on User AND Product */ },
308+
},
309+
&cadwyn.AlterResponseInstruction{
310+
Schemas: []interface{}{Product{}},
311+
Transformer: func(resp) { /* runs on User AND Product */ },
312+
},
313+
)
314+
```
315+
256316
## Architecture: Middleware vs Router Generation
257317

258318
Cadwyn-Go uses a **middleware approach** instead of Python Cadwyn's router generation:

cadwyn/cadwyn.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ type CadwynBuilder struct {
104104
changes []*VersionChange
105105
types []reflect.Type
106106
versionConfig VersionConfig
107+
errors []error // Accumulated errors during building
107108
}
108109

109110
// WithVersions sets the versions for the application
@@ -113,21 +114,29 @@ func (cb *CadwynBuilder) WithVersions(versions ...*Version) *CadwynBuilder {
113114
}
114115

115116
// WithDateVersions creates and adds date-based versions
117+
// Invalid date strings are collected and will cause Build() to fail with a detailed error.
116118
func (cb *CadwynBuilder) WithDateVersions(dates ...string) *CadwynBuilder {
117119
for _, dateStr := range dates {
118-
if v, err := NewDateVersion(dateStr); err == nil {
119-
cb.versions = append(cb.versions, v)
120+
v, err := NewDateVersion(dateStr)
121+
if err != nil {
122+
cb.errors = append(cb.errors, fmt.Errorf("invalid date version '%s': %w", dateStr, err))
123+
continue
120124
}
125+
cb.versions = append(cb.versions, v)
121126
}
122127
return cb
123128
}
124129

125130
// WithSemverVersions creates and adds semantic versions (supports both major.minor.patch and major.minor)
131+
// Invalid semver strings are collected and will cause Build() to fail with a detailed error.
126132
func (cb *CadwynBuilder) WithSemverVersions(semvers ...string) *CadwynBuilder {
127133
for _, semverStr := range semvers {
128-
if v, err := NewSemverVersion(semverStr); err == nil {
129-
cb.versions = append(cb.versions, v)
134+
v, err := NewSemverVersion(semverStr)
135+
if err != nil {
136+
cb.errors = append(cb.errors, fmt.Errorf("invalid semver version '%s': %w", semverStr, err))
137+
continue
130138
}
139+
cb.versions = append(cb.versions, v)
131140
}
132141
return cb
133142
}
@@ -191,6 +200,19 @@ func (cb *CadwynBuilder) WithTypes(types ...interface{}) *CadwynBuilder {
191200

192201
// Build creates the Cadwyn instance
193202
func (cb *CadwynBuilder) Build() (*Cadwyn, error) {
203+
// Check for accumulated errors from builder methods
204+
if len(cb.errors) > 0 {
205+
// Return first error with context about all errors
206+
if len(cb.errors) == 1 {
207+
return nil, fmt.Errorf("builder validation failed: %w", cb.errors[0])
208+
}
209+
errMsg := fmt.Sprintf("builder validation failed with %d errors:", len(cb.errors))
210+
for i, err := range cb.errors {
211+
errMsg += fmt.Sprintf("\n %d. %v", i+1, err)
212+
}
213+
return nil, fmt.Errorf("%s", errMsg)
214+
}
215+
194216
if len(cb.versions) == 0 {
195217
return nil, fmt.Errorf("at least one version must be specified")
196218
}

cadwyn/middleware.go

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9-
"reflect"
109
"regexp"
1110
"strings"
1211

@@ -18,7 +17,6 @@ type VersionLocation string
1817

1918
const (
2019
VersionLocationHeader VersionLocation = "header"
21-
VersionLocationQuery VersionLocation = "query"
2220
VersionLocationPath VersionLocation = "path"
2321
)
2422

@@ -56,23 +54,6 @@ func (hvm *HeaderVersionManager) GetVersion(c *gin.Context) (string, error) {
5654
return version, nil
5755
}
5856

59-
// QueryVersionManager extracts version from query parameters
60-
type QueryVersionManager struct {
61-
paramName string
62-
}
63-
64-
// NewQueryVersionManager creates a new query-based version manager
65-
func NewQueryVersionManager(paramName string) *QueryVersionManager {
66-
return &QueryVersionManager{
67-
paramName: paramName,
68-
}
69-
}
70-
71-
func (qvm *QueryVersionManager) GetVersion(c *gin.Context) (string, error) {
72-
version := c.Query(qvm.paramName)
73-
return version, nil
74-
}
75-
7657
// PathVersionManager extracts version from URL path
7758
type PathVersionManager struct {
7859
versionRegex *regexp.Regexp
@@ -135,18 +116,17 @@ func NewVersionMiddleware(config MiddlewareConfig) *VersionMiddleware {
135116
var versionManager VersionManager
136117

137118
switch config.Location {
138-
case VersionLocationHeader:
139-
versionManager = NewHeaderVersionManager(config.ParameterName)
140-
case VersionLocationQuery:
141-
versionManager = NewQueryVersionManager(config.ParameterName)
142119
case VersionLocationPath:
143120
versions := make([]string, len(config.VersionBundle.GetVersions()))
144121
for i, v := range config.VersionBundle.GetVersions() {
145122
versions[i] = v.String()
146123
}
147124
versionManager = NewPathVersionManager(versions)
125+
case VersionLocationHeader:
126+
fallthrough
148127
default:
149-
versionManager = NewHeaderVersionManager("X-API-Version")
128+
// Default to header-based versioning
129+
versionManager = NewHeaderVersionManager(config.ParameterName)
150130
}
151131

152132
return &VersionMiddleware{
@@ -194,10 +174,16 @@ func (vm *VersionMiddleware) Middleware() gin.HandlerFunc {
194174
requestedVersion = vm.findClosestOlderVersion(versionStr)
195175
}
196176
if requestedVersion == nil {
177+
var hint string
178+
if vm.location == VersionLocationHeader {
179+
hint = fmt.Sprintf("Use '%s' header with one of the available versions", vm.parameterName)
180+
} else {
181+
hint = "Include version in the URL path (e.g., /v1/resource or /2024-01-01/resource)"
182+
}
197183
c.JSON(http.StatusBadRequest, gin.H{
198184
"error": fmt.Sprintf("Unknown version: %s", versionStr),
199185
"available_versions": vm.versionBundle.GetVersionValues(),
200-
"hint": fmt.Sprintf("Use %s header with one of the available versions", vm.parameterName),
186+
"hint": hint,
201187
})
202188
c.Abort()
203189
return
@@ -369,11 +355,24 @@ func (vah *VersionAwareHandler) migrateRequest(c *gin.Context, fromVersion *Vers
369355
return nil // No body to migrate
370356
}
371357

372-
// Parse JSON body directly - ShouldBindJSON handles reading and parsing
358+
// Read body once and preserve it
359+
bodyBytes, err := io.ReadAll(c.Request.Body)
360+
if err != nil {
361+
return fmt.Errorf("failed to read request body: %w", err)
362+
}
363+
c.Request.Body.Close()
364+
365+
// If body is empty, nothing to migrate
366+
if len(bodyBytes) == 0 {
367+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
368+
return nil
369+
}
370+
371+
// Parse JSON body
373372
var bodyData interface{}
374-
if err := c.ShouldBindJSON(&bodyData); err != nil {
375-
// If JSON parsing fails, the body is already consumed
376-
// We can't restore it, so just return (handler will also fail to parse)
373+
if err := json.Unmarshal(bodyBytes, &bodyData); err != nil {
374+
// If JSON parsing fails, restore original body and let handler deal with it
375+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
377376
return nil
378377
}
379378

@@ -385,8 +384,10 @@ func (vah *VersionAwareHandler) migrateRequest(c *gin.Context, fromVersion *Vers
385384
migrationChain := vah.migrationChain.GetMigrationPath(fromVersion, headVersion)
386385

387386
// Apply migrations in forward direction
387+
// Note: We pass nil for bodyType since JSON unmarshaling creates map[string]interface{}
388+
// which doesn't match the original struct types. Schema-based matching doesn't work with JSON.
388389
for _, change := range migrationChain {
389-
if err := change.MigrateRequest(c.Request.Context(), requestInfo, reflect.TypeOf(requestInfo.Body), 0); err != nil {
390+
if err := change.MigrateRequest(c.Request.Context(), requestInfo, nil, 0); err != nil {
390391
return fmt.Errorf("failed to migrate request with change %s: %w", change.Description(), err)
391392
}
392393
}
@@ -427,19 +428,22 @@ func (vah *VersionAwareHandler) migrateResponse(c *gin.Context, toVersion *Versi
427428
migrationChain := vah.migrationChain.GetMigrationPath(headVersion, toVersion)
428429

429430
// Apply migrations in reverse direction
431+
// Note: We pass nil for responseType since JSON unmarshaling creates map[string]interface{}
432+
// which doesn't match the original struct types. Schema-based matching doesn't work with JSON.
430433
for i := len(migrationChain) - 1; i >= 0; i-- {
431434
change := migrationChain[i]
432-
if err := change.MigrateResponse(c.Request.Context(), responseInfo, reflect.TypeOf(responseInfo.Body), 0); err != nil {
435+
if err := change.MigrateResponse(c.Request.Context(), responseInfo, nil, 0); err != nil {
433436
return fmt.Errorf("failed to migrate response with change %s: %w", change.Description(), err)
434437
}
435438
}
436439

437440
// Write the migrated response
438441
c.Writer = responseCapture.ResponseWriter
439-
c.Writer.WriteHeader(responseInfo.StatusCode)
440442

441443
if responseInfo.Body != nil {
442444
c.JSON(responseInfo.StatusCode, responseInfo.Body)
445+
} else {
446+
c.Writer.WriteHeader(responseInfo.StatusCode)
443447
}
444448

445449
return nil

cadwyn/middleware_test.go

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -55,34 +55,6 @@ var _ = Describe("Middleware", func() {
5555
})
5656
})
5757

58-
Describe("QueryVersionManager", func() {
59-
var manager *QueryVersionManager
60-
61-
BeforeEach(func() {
62-
manager = NewQueryVersionManager("version")
63-
})
64-
65-
It("should extract version from query parameter", func() {
66-
req := httptest.NewRequest("GET", "/test?version=1.0.0", nil)
67-
c, _ := gin.CreateTestContext(httptest.NewRecorder())
68-
c.Request = req
69-
70-
version, err := manager.GetVersion(c)
71-
Expect(err).NotTo(HaveOccurred())
72-
Expect(version).To(Equal("1.0.0"))
73-
})
74-
75-
It("should return empty string when query parameter is missing", func() {
76-
req := httptest.NewRequest("GET", "/test", nil)
77-
c, _ := gin.CreateTestContext(httptest.NewRecorder())
78-
c.Request = req
79-
80-
version, err := manager.GetVersion(c)
81-
Expect(err).NotTo(HaveOccurred())
82-
Expect(version).To(Equal(""))
83-
})
84-
})
85-
8658
Describe("PathVersionManager", func() {
8759
var manager *PathVersionManager
8860

@@ -138,17 +110,6 @@ var _ = Describe("Middleware", func() {
138110
Expect(mw).NotTo(BeNil())
139111
})
140112

141-
It("should create middleware with query location", func() {
142-
config := MiddlewareConfig{
143-
VersionBundle: bundle,
144-
MigrationChain: chain,
145-
ParameterName: "version",
146-
Location: VersionLocationQuery,
147-
}
148-
mw := NewVersionMiddleware(config)
149-
Expect(mw).NotTo(BeNil())
150-
})
151-
152113
It("should create middleware with path location", func() {
153114
config := MiddlewareConfig{
154115
VersionBundle: bundle,

0 commit comments

Comments
 (0)