diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 5f7fdb9..d69bbe1 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- go-version: [1.20.x]
+ go-version: [1.24.x]
os: [ubuntu-latest, macos-latest]
steps:
@@ -19,19 +19,19 @@ jobs:
run: echo ::set-output name=tag::$(echo ${GITHUB_SHA:8})
- name: Set up Go
- uses: actions/setup-go@v2
+ uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with:
go-version: ${{ matrix.go-version }}
- name: Cache go modules
- uses: actions/cache@v1
+ uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
restore-keys: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
- name: Check out code
- uses: actions/checkout@v2
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.2
- name: Get and verify dependencies
run: go mod download && go mod verify
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
index 9729cd4..ae84f9a 100644
--- a/.github/workflows/linter.yml
+++ b/.github/workflows/linter.yml
@@ -1,16 +1,20 @@
name: golangci-lint
on:
- pull_request:
+ pull_request:
jobs:
- golangci:
- name: lint
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
- - name: golangci-lint
- uses: golangci/golangci-lint-action@v2
- with:
- version: latest
- only-new-issues: true
- args: --timeout 3m --verbose
+ backend-lint:
+ name: Lint
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.2
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
+ with:
+ args: --config=.golangci.yml --timeout 5m --verbose
+ only-new-issues: 'true'
+ version: v2.1
\ No newline at end of file
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..5075d48
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,292 @@
+# Tiger Style golangci-lint configuration
+# Enforces safety, performance, and developer experience principles
+# Based on https://tigerstyle.dev/
+
+version: "2"
+
+run:
+ timeout: 5m
+ go: "1.23"
+ modules-download-mode: readonly
+ allow-parallel-runners: true
+ allow-serial-runners: true
+
+linters:
+ enable:
+ # Safety: Control flow and limits
+ # - cyclop
+ # - funlen
+
+ # Safety: Error handling
+ # - errcheck
+ - govet
+ - staticcheck
+
+ # Safety: Memory and types
+ - gocritic
+ # - gosec
+
+ # Performance: Resource optimization
+ - prealloc
+ - unparam
+ - wastedassign
+
+ # Developer Experience: Naming and style
+ # - revive
+ - lll
+ - misspell
+ - whitespace
+
+ # Developer Experience: Code organization
+ # - dupl
+ - goprintffuncname
+ - ineffassign
+ - nakedret
+ - nolintlint
+ - rowserrcheck
+ # - err113 # Allow error wrapping patterns
+ - thelper
+ - tparallel
+ - unused
+ # - wrapcheck
+
+ disable:
+ # Disable linters that conflict with Tiger Style principles
+ - depguard # Allow external dependencies when needed
+ - forbidigo # Allow specific patterns when justified
+ - gochecknoinits # Allow init functions when necessary
+ - gochecknoglobals # Allow global variables when justified
+ - gocyclo # Redundant with cyclop
+ - godox # Allow TODO comments
+ - noctx # Allow context-less functions when appropriate
+ - testpackage # Allow test packages
+ - wsl # Allow single-line statements when appropriate
+ # Temporarily disabled linters
+ - cyclop # Cyclomatic complexity
+ - funlen # Function length
+ - errcheck # Unchecked errors
+ - gosec # Security issues
+ - dupl # Duplicate code
+ - err113 # Error wrapping
+ settings:
+ dupl:
+ threshold: 200
+ # Safety: Control flow and limits
+ cyclop:
+ max-complexity: 11 # Keep functions simple and explicit
+ package-average: 7.0
+
+ # Safety: Function length limits (Tiger Style: under 70 lines)
+ funlen:
+ lines: 70
+ statements: 50
+
+ # Safety: Error handling
+ errcheck:
+ check-type-assertions: true
+ check-blank: false
+ exclude-functions:
+ - "Close"
+
+ govet:
+ enable-all: true
+ disable:
+ - shadow
+ - fieldalignment
+ - composites
+
+
+ # Safety: Explicit types and sizes
+ gocritic:
+ enabled-tags:
+ - diagnostic
+ - experimental
+ - opinionated
+ - performance
+ - style
+ disabled-checks:
+ - "regexpMust"
+ - "stringXbytes"
+ - "dupImport" # Allow if needed for clarity
+ - "importShadow"
+ - "ifElseChain" # Allow explicit if-else chains
+ - "unnecessaryDefer" # Allow if it improves error handling
+ - "wrapperFunc" # Allow wrapper functions for clarity
+ - "octalLiteral" #this is just vague preference for one octal style
+ - "whyNoLint"
+ - "hugeParam" #premature optimization, would require pointers spattered everywhere
+ - "rangeValCopy" #same see above
+ - "emptyStringTest" #lem(stringVar) or strings.Trim(stringVar) is basically same
+ - "unnamedResult"
+ - "commentFormatting"
+
+ # Performance: Resource optimization
+ gosec:
+ excludes:
+ - G404 # Allow crypto/rand for non-crypto purposes
+ - G601 # Allow implicit memory aliasing in loops
+ - G115 #
+ - G101
+ - G109 #int16/32 conversions, some packages require diff int types, have to allow thi
+ - G402 #we need to allow insecure client connections/configs for local
+
+ severity: medium
+
+ # Developer Experience: Naming and style
+ revive:
+ rules:
+ - name: exported
+ arguments:
+ - disableStutteringCheck
+ - name: package-comments
+ disabled: true # Allow package comments to be optional
+ - name: var-naming
+ arguments:
+ - idNamingConvention
+ - "ID"
+ - name: function-result-limit
+ arguments:
+ - maxRes: 3
+ - name: function-length
+ arguments:
+ - maxStmt: 50
+ - maxLines: 70
+
+ # Developer Experience: Code organization
+ gocognit:
+ min-complexity: 10
+
+
+ # Developer Experience: Line length limits
+ lll:
+ line-length: 300 # Tiger Style: reasonable line length
+
+ # Safety: Avoid off-by-one errors
+ staticcheck:
+ # go version is now set globally in run.go
+ checks:
+ - "all"
+ - "-SA9009"
+ - "-ST1000"
+ - "-ST1003" #allow all CAPS names where applicable e.g log prefixes
+ - "-ST1020" # Swaggo annotations comments compatibility
+ - "-ST1021" # Swaggo annotation comments compatibility
+ - "-ST1016"
+ - "-ST1022"
+ - "-QF1008"
+ initialisms:
+ - ACL
+ - API
+ - ASCII
+ - CPU
+ - CSS
+ - DNS
+ - EOF
+ - GUID
+ - HTML
+ - HTTP
+ - HTTPS
+ - ID
+ - IP
+ - JSON
+ - QPS
+ - RAM
+ - RPC
+ - SLA
+ - SMTP
+ - SQL
+ - SSH
+ - TCP
+ - TLS
+ - TTL
+ - UDP
+ - UI
+ - UID
+ - UUID
+ - URI
+ - URL
+ - UTF8
+ - VM
+ - XML
+ - XMPP
+ - XSRF
+ - XSS
+
+ exclusions:
+ rules:
+ - path: internal/workers
+ linters:
+ - dupl # currently its allow-able for workers to perform similar repetitive tasks
+ - path: docs/
+ linters:
+ - all
+ - path: ".*_test.go"
+ linters:
+ - funlen
+ - gocognit
+ - cyclop
+ - dupl
+ - errcheck
+ - whitespace
+ - thelper
+ - unused
+ - lll
+ - err113
+ - gocritic
+ - unused
+ - path: cmd/
+ linters:
+ - funlen # Allow longer functions in main commands
+ - errcheck #Allow uncheked e.g db.CLose
+
+ - path: migrations/
+ linters:
+ - funlen
+ - gocognit
+
+ - path: testdata/
+ linters:
+ - all
+
+ - path: mocks/
+ linters:
+ - all
+ - cyclop
+ - path: ".*/pkg/.*"
+ linters:
+ - unused #certain functions in e.g clients may not be used
+ - unparam
+ - path: ".*/middleware/.*"
+ linters:
+ - unused #certain middleware may not be used
+
+ # Allow specific patterns for generated code
+ - text: "generated"
+ linters:
+ - all
+
+formatters:
+ enable:
+ - gci # checks if the code and import statements are formatted according to the 'goimports' command
+ - gofmt # checks if the code is formatted according to 'gofmt' command
+ settings:
+ # Developer Experience: Consistency
+ gofmt:
+ simplify: true
+ gci:
+ sections:
+ - standard # Standard section: captures all standard packages.
+ - default # Default section: contains all imports that could not be matched to another section type.
+ - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled.
+ - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled.
+ - alias # Alias section: contains all alias imports. This section is not present unless explicitly enabled.
+ - localmodule
+
+ exclusions:
+ paths:
+ - ".*_test.go"
+issues:
+ # Treat all warnings as errors (Tiger Style principle)
+ fix: false
+ max-issues-per-linter: 0
+ max-same-issues: 0
\ No newline at end of file
diff --git a/README.md b/README.md
index 05dbe5c..7b3b8f5 100644
--- a/README.md
+++ b/README.md
@@ -1,105 +1,40 @@
-# requestmigrations
[](https://pkg.go.dev/github.com/subomi/requestmigrations)
+# requestmigrations
[](https://pkg.go.dev/github.com/subomi/requestmigrations/v2)
`requestmigrations` is a Golang implementation of [rolling versions](https://stripe.com/blog/api-versioning) for REST APIs. It's a port of the [Ruby implementation](https://github.com/keygen-sh/request_migrations) by [ezekg](https://github.com/ezekg). We use in production with [Convoy](https://github.com/frain-dev/convoy).
## Features
- API Versioning with date and semver versioning support.
- Prometheus Instrumentation to track and optimize slow transformations.
-- Support arbitrary data migration. (Coming soon)
+- Type-based migration system.
## Installation
```bash
- go get github.com/subomi/requestmigrations
+ go get github.com/subomi/requestmigrations/v2
```
## Usage
-This package exposes primarily one API - `Migrate`. It is used to migrate and rollback changes to your request and response respectively. Here's a short example:
+RequestMigrations introduces a **type-based migration system**. Instead of defining migrations per API handler, migrations are now defined per **Go type**.
```go
-package main
+package main
-func createUser(r *http.Request, w http.ResponseWriter) {
- // Identify version and transform the request payload.
- err, vw, rollback := rm.Migrate(r, "createUser")
- if err != nil {
- w.Write("Bad Request")
- }
+import (
+ rms "github.com/subomi/requestmigrations/v2"
+)
- // Setup response transformation callback.
- defer rollback(w)
+func main() {
+ rm, _ := rms.NewRequestMigration(&rms.RequestMigrationOptions{
+ VersionHeader: "X-API-Version",
+ CurrentVersion: "2024-01-01",
+ VersionFormat: rms.DateFormat,
+ })
- // ...Perform core business logic...
- data, err := createUserObject(body)
- if err != nil {
- return err
- }
-
- // Write response
- body, err := json.Marshal(data)
- if err != nil {
- w.Write("Bad Request")
- }
-
- vw.Write(body)
+ // Register migrations for a specific type
+ rms.Register[User](rm, "2024-01-01", &UserMigration{})
}
-
-```
-
-### Writing migrations
-A migration is a struct that performs a migration on either a request or a response, but not both. Here's an example:
-
-```go
- type createUserRequestSplitNameMigration struct{}
-
- func (c *createUserRequestSplitNameMigration) Migrate(body []byte, h http.Header) ([]byte, http.Header, error) {
- var oUser oldUser
- err := json.Unmarshal(body, &oUser)
- if err != nil {
- return nil, nil, err
- }
-
- var nUser user
- nUser.Email = oUser.Email
-
- splitName := strings.Split(oUser.FullName, " ")
- nUser.FirstName = splitName[0]
- nUser.LastName = splitName[1]
-
- body, err = json.Marshal(&nUser)
- if err != nil {
- return nil, nil, err
- }
-
- return body, h, nil
- }
```
-Notice from the above that the migration struct name follows a particular structure. The structure adopted is `{handlerName}{MigrationType}`. The `handlerName` refers to the exact name of your handler. For example, if you have a handler named `LoginUser`, any migration on this handler should start with `LoginUser`. It'll also be what we use in `VersionRequest` and `VersionResponse`. The `MigrationType` can be `Request` or `Response`. We use this field to determine if the migration should run on the request or the response payload.
-
-This library doesn't support multiple transformations per version as of the time of this writing. For example, no handler can have multiple changes for the same version.
-
## Example
-Check the [example](./example) directory for a full example. Do the following to run the example:
-
-1. Run the server.
-```bash
-$ git clone https://github.com/subomi/requestmigrations
-
-$ cd example/basic
-
-$ go run *.go
-```
-
-2. Open another terminal and call the server
-```bash
-# Call the API without specifying a version.
-$ curl -s localhost:9000/users \
- -H "Content-Type: application/json" | jq
-
-# Call the API with 2023-04-01 version.
-$ curl -s localhost:9000/users \
- -H "Content-Type: application/json" \
- -H "X-Example-Version: 2023-04-01" | jq
-```
+Check the [examples](./examples) directory for full examples.
## License
MIT License
diff --git a/example/advanced/main.go b/example/advanced/main.go
deleted file mode 100644
index ad14291..0000000
--- a/example/advanced/main.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package main
-
-import "fmt"
-
-func main() {
- fmt.Println("TODO: Write a more complex example")
-}
diff --git a/example/basic/requestmigrations.go b/example/basic/requestmigrations.go
deleted file mode 100644
index a9f6dab..0000000
--- a/example/basic/requestmigrations.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package main
-
-import (
- "basicexample/helper"
- "encoding/json"
- "net/http"
- "net/url"
- "strings"
- "time"
-)
-
-// Migrations
-type ListUserResponseMigration struct{}
-
-func (c *ListUserResponseMigration) ShouldMigrateConstraint(
- url *url.URL,
- method string,
- data []byte,
- isReq bool) bool {
-
- isUserPath := url.Path == "/users"
- isGetMethod := method == http.MethodGet
- isValidType := isReq == false
-
- return isUserPath && isGetMethod && isValidType
-}
-
-func (c *ListUserResponseMigration) Migrate(
- body []byte,
- h http.Header) ([]byte, http.Header, error) {
- type oldUser struct {
- UID string `json:"uid"`
- Email string `json:"email"`
- FullName string `json:"full_name"`
- Profile string `json:"profile"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- }
-
- var res helper.ServerResponse
- err := json.Unmarshal(body, &res)
- if err != nil {
- return nil, nil, err
- }
-
- var users []*oldUser20230501
- err = json.Unmarshal(res.Data, &users)
- if err != nil {
- return nil, nil, err
- }
-
- var newUsers []*oldUser
- for _, u := range users {
- var oldUser oldUser
- oldUser.UID = u.UID
- oldUser.Email = u.Email
- oldUser.FullName = strings.Join([]string{u.FirstName, u.LastName}, " ")
- oldUser.Profile = u.Profile
- oldUser.CreatedAt = u.CreatedAt
- oldUser.UpdatedAt = u.UpdatedAt
- newUsers = append(newUsers, &oldUser)
- }
-
- body, err = helper.GenerateSuccessResponse(&newUsers, "users retrieved successfully")
- if err != nil {
- return nil, nil, err
- }
-
- return body, h, nil
-}
-
-type oldUser20230501 struct {
- UID string `json:"uid"`
- Email string `json:"email"`
- FirstName string `json:"first_name"`
- LastName string `json:"last_name"`
- Profile string `json:"profile"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
-}
-
-type expandProfileForUserMigration struct{}
-
-func (e *expandProfileForUserMigration) ShouldMigrateConstraint(
- url *url.URL,
- method string,
- body []byte,
- isReq bool) bool {
-
- isUserPath := url.Path == "/users"
- isGetMethod := method == http.MethodGet
- isValidType := isReq == false
-
- return isUserPath && isGetMethod && isValidType
-}
-
-func (e *expandProfileForUserMigration) Migrate(
- body []byte,
- h http.Header) ([]byte, http.Header, error) {
- var res helper.ServerResponse
- err := json.Unmarshal(body, &res)
- if err != nil {
- return nil, nil, err
- }
-
- var users []*User
- err = json.Unmarshal(res.Data, &users)
- if err != nil {
- return nil, nil, err
- }
-
- var newUsers []*oldUser20230501
- for _, u := range users {
- var oldUser oldUser20230501
- oldUser.UID = u.UID
- oldUser.Email = u.Email
- oldUser.FirstName = u.FirstName
- oldUser.LastName = u.LastName
- oldUser.Profile = u.Profile.UID
- oldUser.CreatedAt = u.CreatedAt
- oldUser.UpdatedAt = u.UpdatedAt
- newUsers = append(newUsers, &oldUser)
- }
-
- body, err = helper.GenerateSuccessResponse(&newUsers, "users retrieved successfully")
- if err != nil {
- return nil, nil, err
- }
-
- return body, h, nil
-}
diff --git a/example/basic/v20230401/requestmigrations.go b/example/basic/v20230401/requestmigrations.go
deleted file mode 100644
index 29066b2..0000000
--- a/example/basic/v20230401/requestmigrations.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package v20230401
-
-import (
- "basicexample/helper"
- "encoding/json"
- "net/http"
- "strings"
- "time"
-)
-
-// Migrations
-type ListUserResponseMigration struct{}
-
-func (c *ListUserResponseMigration) Migrate(
- body []byte,
- h http.Header) ([]byte, http.Header, error) {
- type oldUser struct {
- UID string `json:"uid"`
- Email string `json:"email"`
- FullName string `json:"full_name"`
- Profile string `json:"profile"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- }
-
- var res helper.ServerResponse
- err := json.Unmarshal(body, &res)
- if err != nil {
- return nil, nil, err
- }
-
- var users []*oldUser20230501
- err = json.Unmarshal(res.Data, &users)
- if err != nil {
- return nil, nil, err
- }
-
- var newUsers []*oldUser
- for _, u := range users {
- var oldUser oldUser
- oldUser.UID = u.UID
- oldUser.Email = u.Email
- oldUser.FullName = strings.Join([]string{u.FirstName, u.LastName}, " ")
- oldUser.Profile = u.Profile
- oldUser.CreatedAt = u.CreatedAt
- oldUser.UpdatedAt = u.UpdatedAt
- newUsers = append(newUsers, &oldUser)
- }
-
- body, err = helper.GenerateSuccessResponse(&newUsers, "users retrieved successfully")
- if err != nil {
- return nil, nil, err
- }
-
- return body, h, nil
-}
-
-type oldUser20230501 struct {
- UID string `json:"uid"`
- Email string `json:"email"`
- FirstName string `json:"first_name"`
- LastName string `json:"last_name"`
- Profile string `json:"profile"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
-}
diff --git a/example/basic/v20230501/requestmigrations.go b/example/basic/v20230501/requestmigrations.go
deleted file mode 100644
index 75be5c2..0000000
--- a/example/basic/v20230501/requestmigrations.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package v20230501
-
-import (
- "basicexample/helper"
- "encoding/json"
- "net/http"
- "time"
-)
-
-type User struct {
- UID string `json:"uid"`
- Email string `json:"email"`
- FirstName string `json:"first_name"`
- LastName string `json:"last_name"`
- Profile *profile `json:"profile"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
-}
-
-type profile struct {
- UID string `json:"uid"`
- GithubURL string `json:"github_url"`
- TwitterURL string `json:"twitter_url"`
-}
-
-// Migrations
-type oldUser20230501 struct {
- UID string `json:"uid"`
- Email string `json:"email"`
- FirstName string `json:"first_name"`
- LastName string `json:"last_name"`
- Profile string `json:"profile"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
-}
-
-type ListUserResponseMigration struct{}
-
-func (e *ListUserResponseMigration) Migrate(
- body []byte, h http.Header) ([]byte, http.Header, error) {
- var res helper.ServerResponse
- err := json.Unmarshal(body, &res)
- if err != nil {
- return nil, nil, err
- }
-
- var users []*User
- err = json.Unmarshal(res.Data, &users)
- if err != nil {
- return nil, nil, err
- }
-
- var newUsers []*oldUser20230501
- for _, u := range users {
- var oldUser oldUser20230501
- oldUser.UID = u.UID
- oldUser.Email = u.Email
- oldUser.FirstName = u.FirstName
- oldUser.LastName = u.LastName
- oldUser.Profile = u.Profile.UID
- oldUser.CreatedAt = u.CreatedAt
- oldUser.UpdatedAt = u.UpdatedAt
- newUsers = append(newUsers, &oldUser)
- }
-
- body, err = helper.GenerateSuccessResponse(&newUsers, "users retrieved successfully")
- if err != nil {
- return nil, nil, err
- }
-
- return body, h, nil
-}
diff --git a/examples/advanced/go.mod b/examples/advanced/go.mod
new file mode 100644
index 0000000..1464779
--- /dev/null
+++ b/examples/advanced/go.mod
@@ -0,0 +1,21 @@
+module advancedexample
+
+go 1.24
+
+require github.com/subomi/requestmigrations/v2 v2.0.0
+
+require (
+ github.com/Masterminds/semver/v3 v3.4.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/prometheus/client_golang v1.20.5 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/common v0.55.0 // indirect
+ github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
+ golang.org/x/sys v0.22.0 // indirect
+ google.golang.org/protobuf v1.34.2 // indirect
+)
+
+replace github.com/subomi/requestmigrations/v2 v2.0.0 => ../../
diff --git a/examples/advanced/go.sum b/examples/advanced/go.sum
new file mode 100644
index 0000000..c123ef5
--- /dev/null
+++ b/examples/advanced/go.sum
@@ -0,0 +1,32 @@
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
+github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
+github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/advanced/main.go b/examples/advanced/main.go
new file mode 100644
index 0000000..88d055b
--- /dev/null
+++ b/examples/advanced/main.go
@@ -0,0 +1,147 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/http/httptest"
+
+ rms "github.com/subomi/requestmigrations/v2"
+)
+
+// --- Models (Current Version) ---
+
+type Workspace struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Users []*User `json:"users"`
+ Projects map[string]*Project `json:"projects"` // Changed from slice in v2024-01-01
+}
+
+type User struct {
+ ID string `json:"id"`
+ Username string `json:"username"` // Renamed from email in v2023-06-01
+}
+
+type Project struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Lead *User `json:"lead"`
+}
+
+// --- Migrations ---
+
+// UserMigration handles email -> username renaming
+type UserMigrationV20230601 struct{}
+
+func (m *UserMigrationV20230601) MigrateForward(data any) (any, error) {
+ d := data.(map[string]any)
+ if email, ok := d["email"].(string); ok {
+ d["username"] = email
+ delete(d, "email")
+ }
+ return d, nil
+}
+
+func (m *UserMigrationV20230601) MigrateBackward(data any) (any, error) {
+ d := data.(map[string]any)
+ if username, ok := d["username"].(string); ok {
+ d["email"] = username
+ delete(d, "username")
+ }
+ return d, nil
+}
+
+// WorkspaceMigration handles projects slice -> map conversion
+type WorkspaceMigrationV20240101 struct{}
+
+func (m *WorkspaceMigrationV20240101) MigrateForward(data any) (any, error) {
+ d := data.(map[string]any)
+ if projects, ok := d["projects"].([]any); ok {
+ projectMap := make(map[string]any)
+ for _, p := range projects {
+ pm := p.(map[string]any)
+ if id, ok := pm["id"].(string); ok {
+ projectMap[id] = pm
+ }
+ }
+ d["projects"] = projectMap
+ }
+ return d, nil
+}
+
+func (m *WorkspaceMigrationV20240101) MigrateBackward(data any) (any, error) {
+ d := data.(map[string]any)
+ if projectMap, ok := d["projects"].(map[string]any); ok {
+ projects := make([]any, 0, len(projectMap))
+ for _, p := range projectMap {
+ projects = append(projects, p)
+ }
+ d["projects"] = projects
+ }
+ return d, nil
+}
+
+func main() {
+ // Initialize RequestMigration
+ rm, err := rms.NewRequestMigration(&rms.RequestMigrationOptions{
+ VersionHeader: "X-API-Version",
+ CurrentVersion: "2024-01-01",
+ VersionFormat: rms.DateFormat,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Register migrations across versions
+ rms.Register[User](rm, "2023-06-01", &UserMigrationV20230601{})
+ rms.Register[Workspace](rm, "2024-01-01", &WorkspaceMigrationV20240101{})
+
+ // --- Scenario: Backward Migration (Marshal) ---
+ // Current data structure
+ w := &Workspace{
+ ID: "w1",
+ Name: "Main Workspace",
+ Projects: map[string]*Project{
+ "p1": {ID: "p1", Name: "Project 1"},
+ },
+ }
+ u1 := &User{ID: "u1", Username: "subomi@example.com"}
+ w.Users = []*User{u1}
+ w.Projects["p1"].Lead = u1
+
+ fmt.Println("--- Advanced Example: Multi-version Migration ---")
+
+ // Client requesting v2023-01-01 (oldest version)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("X-API-Version", "2023-01-01")
+
+ data, err := rm.WithUserVersion(req).Marshal(w)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ var prettyJSON any
+ _ = json.Unmarshal(data, &prettyJSON)
+ indentedData, _ := json.MarshalIndent(prettyJSON, "", " ")
+ fmt.Printf("Marshaled for v2023-01-01:\n%s\n\n", string(indentedData))
+
+ // --- Scenario: Forward Migration (Unmarshal) ---
+ // Client sending data in v2023-01-01 format (email instead of username, projects as slice)
+ oldJSON := `{
+ "id": "w1",
+ "name": "Old Format Workspace",
+ "users": [{"id": "u1", "email": "old@example.com"}],
+ "projects": [{"id": "p1", "name": "Old Project"}]
+ }`
+
+ var incoming Workspace
+ err = rm.WithUserVersion(req).Unmarshal([]byte(oldJSON), &incoming)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ incomingJSON, _ := json.MarshalIndent(incoming, "", " ")
+ fmt.Printf("Unmarshaled from v2023-01-01 to Current:\n%s\n", string(incomingJSON))
+}
diff --git a/example/basic/call_api.sh b/examples/basic/call_api.sh
similarity index 100%
rename from example/basic/call_api.sh
rename to examples/basic/call_api.sh
diff --git a/example/basic/go.mod b/examples/basic/go.mod
similarity index 79%
rename from example/basic/go.mod
rename to examples/basic/go.mod
index 19f32f4..75493a7 100644
--- a/example/basic/go.mod
+++ b/examples/basic/go.mod
@@ -1,11 +1,11 @@
module basicexample
-go 1.18
+go 1.24
require (
github.com/gorilla/mux v1.8.0
github.com/prometheus/client_golang v1.20.5
- github.com/subomi/requestmigrations v0.6.0
+ github.com/subomi/requestmigrations/v2 v2.0.0
)
require (
@@ -17,8 +17,9 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/sys v0.22.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
-replace github.com/subomi/requestmigrations v0.1.0 => ../../
+replace github.com/subomi/requestmigrations/v2 v2.0.0 => ../../
diff --git a/example/basic/go.sum b/examples/basic/go.sum
similarity index 80%
rename from example/basic/go.sum
rename to examples/basic/go.sum
index 10a4314..f1f035d 100644
--- a/example/basic/go.sum
+++ b/examples/basic/go.sum
@@ -5,15 +5,19 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
@@ -22,11 +26,11 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/subomi/requestmigrations v0.6.0 h1:aTW5K6YNYk9V4IjiFlgKFpZqnNRe16/f5EH65MrC6q8=
-github.com/subomi/requestmigrations v0.6.0/go.mod h1:OSnwcic6ImO9S7IC6sXYaOBSwoPtvJnDBKHeSZmg5cw=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/example/basic/helper/helper.go b/examples/basic/helper/helper.go
similarity index 78%
rename from example/basic/helper/helper.go
rename to examples/basic/helper/helper.go
index 6ee8cef..86cf060 100644
--- a/example/basic/helper/helper.go
+++ b/examples/basic/helper/helper.go
@@ -14,6 +14,10 @@ func GenerateSuccessResponse(payload interface{}, message string) ([]byte, error
return nil, err
}
+ return GenerateSuccessResponseFromRaw(data, message)
+}
+
+func GenerateSuccessResponseFromRaw(data json.RawMessage, message string) ([]byte, error) {
s := &ServerResponse{
Status: true,
Message: message,
diff --git a/example/basic/main.go b/examples/basic/main.go
similarity index 60%
rename from example/basic/main.go
rename to examples/basic/main.go
index 6a4dc09..f88fcf0 100644
--- a/example/basic/main.go
+++ b/examples/basic/main.go
@@ -2,8 +2,6 @@ package main
import (
"basicexample/helper"
- v20230401 "basicexample/v20230401"
- v20230501 "basicexample/v20230501"
"log"
"math/rand"
"net/http"
@@ -14,7 +12,7 @@ import (
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
- rms "github.com/subomi/requestmigrations"
+ rms "github.com/subomi/requestmigrations/v2"
)
func main() {
@@ -29,14 +27,9 @@ func main() {
log.Fatal(err)
}
- rm.RegisterMigrations(rms.MigrationStore{
- "2023-05-01": []rms.Migration{
- &v20230501.ListUserResponseMigration{},
- },
- "2023-04-01": []rms.Migration{
- &v20230401.ListUserResponseMigration{},
- },
- })
+ // Register migrations for the User and profile types
+ rms.Register[User](rm, "2023-05-01", &UserMigration{})
+ rms.Register[profile](rm, "2023-05-01", &ProfileMigration{})
api := &API{rm: rm, store: userStore}
backend := http.Server{
@@ -44,14 +37,17 @@ func main() {
Handler: buildMux(api),
}
- err = backend.ListenAndServe()
- if err != nil {
- log.Fatal(err)
- }
+ go func() {
+ log.Println("Starting server on :9000")
+ if err := backend.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("listen: %s\n", err)
+ }
+ }()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
+ log.Println("Shutting down server...")
}
func buildMux(api *API) http.Handler {
@@ -69,25 +65,14 @@ func buildMux(api *API) http.Handler {
return m
}
-// api models
-
-// define api
type API struct {
store *Store
rm *rms.RequestMigration
}
func (a *API) ListUser(w http.ResponseWriter, r *http.Request) {
- err, vw, rollback := a.rm.Migrate(r, "ListUser")
- if err != nil {
- w.WriteHeader(http.StatusBadRequest)
- return
- }
-
- defer rollback(w)
-
- // Generate a random Int type number between 1 and 10
- randNum := rand.Intn(2-1+1) + 1
+ // Generate a random delay
+ randNum := rand.Intn(2) + 1
time.Sleep(time.Duration(randNum) * time.Second)
users, err := a.store.GetAll()
@@ -96,13 +81,21 @@ func (a *API) ListUser(w http.ResponseWriter, r *http.Request) {
return
}
- res, err := helper.GenerateSuccessResponse(users, "users retrieved successfully")
+ // Use the new API to marshal and migrate the users
+ data, err := a.rm.WithUserVersion(r).Marshal(users)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ res, err := helper.GenerateSuccessResponseFromRaw(data, "users retrieved successfully")
if err != nil {
w.WriteHeader(http.StatusBadGateway)
return
}
- vw.Write(res)
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(res)
}
func (a *API) GetUser(w http.ResponseWriter, r *http.Request) {
@@ -113,11 +106,19 @@ func (a *API) GetUser(w http.ResponseWriter, r *http.Request) {
return
}
- res, err := helper.GenerateSuccessResponse(user, "user retrieved successfully")
+ // Use the new API to marshal and migrate the user
+ data, err := a.rm.WithUserVersion(r).Marshal(user)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ res, err := helper.GenerateSuccessResponseFromRaw(data, "user retrieved successfully")
if err != nil {
w.WriteHeader(http.StatusBadGateway)
return
}
+ w.Header().Set("Content-Type", "application/json")
w.Write(res)
}
diff --git a/examples/basic/requestmigrations.go b/examples/basic/requestmigrations.go
new file mode 100644
index 0000000..16684ab
--- /dev/null
+++ b/examples/basic/requestmigrations.go
@@ -0,0 +1,66 @@
+package main
+
+import (
+ "strings"
+)
+
+// UserMigration handles migrations for the User type
+type UserMigration struct{}
+
+func (m *UserMigration) MigrateForward(data any) (any, error) {
+ d, ok := data.(map[string]any)
+ if !ok {
+ return data, nil
+ }
+
+ // If we have full_name but no first_name/last_name, split it
+ if fullName, ok := d["full_name"].(string); ok {
+ parts := strings.Split(fullName, " ")
+ if len(parts) > 0 {
+ d["first_name"] = parts[0]
+ }
+ if len(parts) > 1 {
+ d["last_name"] = parts[1]
+ }
+ delete(d, "full_name")
+ }
+
+ return d, nil
+}
+
+func (m *UserMigration) MigrateBackward(data any) (any, error) {
+ d, ok := data.(map[string]any)
+ if !ok {
+ return data, nil
+ }
+
+ // Join first_name and last_name into full_name for older versions
+ firstName, _ := d["first_name"].(string)
+ lastName, _ := d["last_name"].(string)
+ d["full_name"] = strings.TrimSpace(firstName + " " + lastName)
+
+ return d, nil
+}
+
+// ProfileMigration handles migrations for the profile type if needed
+type ProfileMigration struct{}
+
+func (m *ProfileMigration) MigrateForward(data any) (any, error) {
+ // If it's just a string (old format), we can't fully reconstruct the profile
+ // but we can at least put the UID in.
+ if uid, ok := data.(string); ok {
+ return map[string]any{
+ "uid": uid,
+ }, nil
+ }
+ return data, nil
+}
+
+func (m *ProfileMigration) MigrateBackward(data any) (any, error) {
+ d, ok := data.(map[string]any)
+ if !ok {
+ return data, nil
+ }
+ // In older versions, profile was just the UID string
+ return d["uid"], nil
+}
diff --git a/example/basic/store.go b/examples/basic/store.go
similarity index 97%
rename from example/basic/store.go
rename to examples/basic/store.go
index a5c05fd..49481bd 100644
--- a/example/basic/store.go
+++ b/examples/basic/store.go
@@ -2,7 +2,6 @@ package main
import (
"errors"
- "sync"
"time"
)
@@ -72,7 +71,6 @@ type profile struct {
}
type Store struct {
- mu sync.Mutex
users []*User
}
@@ -83,7 +81,7 @@ func (s *Store) Get(id string) (*User, error) {
}
}
- return nil, errors.New("Not Found")
+ return nil, errors.New("not found")
}
func (s *Store) GetAll() ([]*User, error) {
diff --git a/go.mod b/go.mod
index cfa0516..ac5a29b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
-module github.com/subomi/requestmigrations
+module github.com/subomi/requestmigrations/v2
-go 1.20
+go 1.24
require (
github.com/Masterminds/semver/v3 v3.2.1
diff --git a/go.sum b/go.sum
index 148ec60..3103605 100644
--- a/go.sum
+++ b/go.sum
@@ -8,8 +8,11 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -34,5 +37,6 @@ google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6h
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/requestmigrations.go b/requestmigrations.go
index 14a2968..000719f 100644
--- a/requestmigrations.go
+++ b/requestmigrations.go
@@ -1,9 +1,8 @@
package requestmigrations
import (
- "bytes"
+ "encoding/json"
"errors"
- "io"
"net/http"
"reflect"
"sort"
@@ -28,31 +27,13 @@ var (
ErrCurrentVersionCannotBeEmpty = errors.New("current version field cannot be empty")
)
-// Migration is the core interface each transformation in every version
-// needs to implement. It includes two predicate functions and two
-// transformation functions.
-type Migration interface {
- Migrate(data []byte, header http.Header) ([]byte, http.Header, error)
-}
-
-// Migrations is an array of migrations declared by each handler.
-type Migrations []Migration
-
-// migrations := Migrations{
-// "2023-02-28": []Migration{
-// Migration{},
-// Migration{},
-// },
-// }
-type MigrationStore map[string]Migrations
-
type GetUserVersionFunc func(req *http.Request) (string, error)
// RequestMigrationOptions is used to configure the RequestMigration type.
type RequestMigrationOptions struct {
// VersionHeader refers to the header value used to retrieve the request's
// version. If VersionHeader is empty, we call the GetUserVersionFunc to
- // retrive the user's version.
+ // retrieve the user's version.
VersionHeader string
// CurrentVersion refers to the API's most recent version. This value should
@@ -69,8 +50,6 @@ type RequestMigrationOptions struct {
VersionFormat VersionFormat
}
-type rollbackFn func(w http.ResponseWriter)
-
// RequestMigration is the exported type responsible for handling request migrations.
type RequestMigration struct {
opts *RequestMigrationOptions
@@ -78,8 +57,18 @@ type RequestMigration struct {
metric *prometheus.HistogramVec
iv string
- mu sync.Mutex
- migrations MigrationStore
+ mu *sync.Mutex
+ migrations map[reflect.Type]map[string]TypeMigration // type -> version -> migration
+
+ cacheMu *sync.RWMutex
+ cache map[graphCacheKey]*TypeGraph
+
+ request *http.Request // context for a specific request
+}
+
+type graphCacheKey struct {
+ t reflect.Type
+ version string
}
func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, error) {
@@ -97,17 +86,14 @@ func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, erro
}, []string{"from", "to"})
var iv string
- if opts.VersionFormat == DateFormat {
+ switch opts.VersionFormat {
+ case DateFormat:
iv = new(time.Time).Format(time.DateOnly)
- } else if opts.VersionFormat == SemverFormat {
+ case SemverFormat:
iv = "v0"
}
- migrations := MigrationStore{
- iv: []Migration{},
- }
-
- var versions []*Version
+ versions := make([]*Version, 0, 1)
versions = append(versions, &Version{Format: opts.VersionFormat, Value: iv})
return &RequestMigration{
@@ -115,109 +101,15 @@ func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, erro
metric: me,
iv: iv,
versions: versions,
- migrations: migrations,
+ mu: new(sync.Mutex),
+ migrations: make(map[reflect.Type]map[string]TypeMigration),
+ cacheMu: new(sync.RWMutex),
+ cache: make(map[graphCacheKey]*TypeGraph),
}, nil
}
-func (rm *RequestMigration) RegisterMigrations(migrations MigrationStore) error {
- rm.mu.Lock()
- defer rm.mu.Unlock()
-
- for k, v := range migrations {
- rm.migrations[k] = v
- rm.versions = append(rm.versions, &Version{Format: rm.opts.VersionFormat, Value: k})
- }
-
- switch rm.opts.VersionFormat {
- case SemverFormat:
- sort.Slice(rm.versions, semVerSorter(rm.versions))
- case DateFormat:
- sort.Slice(rm.versions, dateVersionSorter(rm.versions))
- default:
- return ErrInvalidVersionFormat
- }
-
- return nil
-}
-
-// Migrate is the core API for apply transformations to your handlers. It should be
-// called at the start of your handler to transform the body attached to your request
-// before further processing. To transform the response as well, you need to use
-// the rollback and res function to roll changes back and set the handler response
-// respectively.
-func (rm *RequestMigration) Migrate(r *http.Request, handler string) (error, *response, rollbackFn) {
- err := rm.migrateRequest(r, handler)
- if err != nil {
- return err, nil, nil
- }
-
- res := &response{}
- rollback := func(w http.ResponseWriter) {
- res.body, err = rm.migrateResponse(r, res.body, handler)
- if err != nil {
- // write an error to the client.
- return
- }
-
- err = rm.writeResponseToClient(w, res)
- if err != nil {
- // write an error to the client.
- return
- }
- }
-
- return nil, res, rollback
-}
-
-func (rm *RequestMigration) migrateRequest(r *http.Request, handler string) error {
- from, err := rm.getUserVersion(r)
- if err != nil {
- return err
- }
-
- to := rm.getCurrentVersion()
- m, err := Newmigrator(from, to, rm.versions, rm.migrations)
- if err != nil {
- return err
- }
-
- if from.Equal(to) {
- return nil
- }
-
- startTime := time.Now()
- defer rm.observeRequestLatency(from, to, startTime)
-
- err = m.applyRequestMigrations(r, handler)
- if err != nil {
- return err
- }
-
- return nil
-}
-
-func (rm *RequestMigration) migrateResponse(r *http.Request, body []byte, handler string) ([]byte, error) {
- from, err := rm.getUserVersion(r)
- if err != nil {
- return nil, err
- }
-
- to := rm.getCurrentVersion()
- m, err := Newmigrator(from, to, rm.versions, rm.migrations)
- if err != nil {
- return nil, err
- }
-
- if from.Equal(to) {
- return body, nil
- }
-
- return m.applyResponseMigrations(r, r.Header, body, handler)
-}
-
func (rm *RequestMigration) getUserVersion(req *http.Request) (*Version, error) {
- var vh string
- vh = req.Header.Get(rm.opts.VersionHeader)
+ var vh = req.Header.Get(rm.opts.VersionHeader)
if !isStringEmpty(vh) {
return &Version{
@@ -286,139 +178,397 @@ func (rm *RequestMigration) RegisterMetrics(reg *prometheus.Registry) {
reg.MustRegister(rm.metric)
}
-func (rm *RequestMigration) writeResponseToClient(w http.ResponseWriter, res *response) error {
- if res.statusCode != 0 {
- w.WriteHeader(res.statusCode)
+func (rm *RequestMigration) WithUserVersion(r *http.Request) *RequestMigration {
+ newRM := *rm
+ newRM.request = r
+ return &newRM
+}
+
+func (rm *RequestMigration) Marshal(v interface{}) ([]byte, error) {
+ startTime := time.Now()
+
+ var (
+ err error
+ userVersion *Version
+ )
+
+ if rm.request != nil {
+ userVersion, err = rm.getUserVersion(rm.request)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ userVersion = rm.getCurrentVersion()
+ }
+
+ graph, err := rm.buildTypeGraph(reflect.TypeOf(v), userVersion)
+ if err != nil {
+ return nil, err
+ }
+
+ if !graph.HasMigrations() {
+ return json.Marshal(v)
+ }
+
+ currentVersion := rm.getCurrentVersion()
+
+ data, err := json.Marshal(v)
+ if err != nil {
+ return nil, err
+ }
+
+ var intermediate any
+ if err := json.Unmarshal(data, &intermediate); err != nil {
+ return nil, err
+ }
+
+ if err := rm.migrateBackward(graph, &intermediate); err != nil {
+ return nil, err
+ }
+
+ result, err := json.Marshal(intermediate)
+ if err != nil {
+ return nil, err
+ }
+
+ // Observe migration latency: from current version -> to user version (backward migration)
+ rm.observeRequestLatency(currentVersion, userVersion, startTime)
+
+ return result, nil
+}
+
+func (rm *RequestMigration) Unmarshal(data []byte, v interface{}) error {
+ startTime := time.Now()
+
+ var (
+ err error
+ userVersion *Version
+ )
+
+ if rm.request != nil {
+ userVersion, err = rm.getUserVersion(rm.request)
+ if err != nil {
+ return err
+ }
+ } else {
+ userVersion = rm.getCurrentVersion()
+ }
+
+ t := reflect.TypeOf(v)
+ if t.Kind() != reflect.Ptr {
+ return errors.New("v must be a pointer")
}
- _, err := w.Write(res.body)
+ graph, err := rm.buildTypeGraph(t, userVersion)
if err != nil {
return err
}
- // TODO(subomi): log bytesWritten
+ if !graph.HasMigrations() {
+ return json.Unmarshal(data, v)
+ }
+
+ currentVersion := rm.getCurrentVersion()
+
+ var intermediate any
+ if err := json.Unmarshal(data, &intermediate); err != nil {
+ return err
+ }
+
+ if err := rm.migrateForward(graph, &intermediate); err != nil {
+ return err
+ }
+
+ data, err = json.Marshal(intermediate)
+ if err != nil {
+ return err
+ }
+
+ if err := json.Unmarshal(data, v); err != nil {
+ return err
+ }
+
+ // Observe migration latency: from user version -> to current version (forward migration)
+ rm.observeRequestLatency(userVersion, currentVersion, startTime)
+
return nil
}
-type migrator struct {
- to *Version
- from *Version
- versions []*Version
- migrations MigrationStore
+// TypeMigration defines how to migrate a specific type
+type TypeMigration interface {
+ // MigrateForward transforms data from old version to new
+ MigrateForward(data any) (any, error)
+
+ // MigrateBackward transforms data from new version to old
+ MigrateBackward(data any) (any, error)
}
-func Newmigrator(from, to *Version, avs []*Version, migrations MigrationStore) (*migrator, error) {
- if !from.IsValid() || !to.IsValid() {
- return nil, ErrInvalidVersion
+// MigrationVersion represents all type migrations for a specific version
+type MigrationVersion struct {
+ Version string
+ Migrations map[reflect.Type]TypeMigration
+}
+
+// TypeGraph represents dependencies between types
+type TypeGraph struct {
+ Type reflect.Type
+ Fields map[string]*TypeGraph // field name -> nested type graph
+ Migrations []TypeMigration
+}
+
+func (rm *RequestMigration) buildTypeGraph(t reflect.Type, userVersion *Version) (*TypeGraph, error) {
+ if t.Kind() == reflect.Ptr {
+ t = t.Elem()
}
- var versions []*Version
- for i, v := range avs {
- if v.Equal(from) {
- versions = avs[i:]
- break
- }
+ key := graphCacheKey{
+ t: t,
+ version: userVersion.String(),
}
- return &migrator{
- to: to,
- from: from,
- versions: versions,
- migrations: migrations,
- }, nil
-}
+ // 1. Check cache (O(1))
+ rm.cacheMu.RLock()
+ if g, ok := rm.cache[key]; ok {
+ rm.cacheMu.RUnlock()
+ return g, nil
+ }
+ rm.cacheMu.RUnlock()
-func (m *migrator) applyRequestMigrations(req *http.Request, handler string) error {
- if m.versions == nil {
- return nil
+ // 2. Build graph (DFS with internal cycle detection)
+ rm.cacheMu.Lock()
+ defer rm.cacheMu.Unlock()
+
+ // Re-check cache after acquiring write lock
+ if g, ok := rm.cache[key]; ok {
+ return g, nil
}
- data, err := io.ReadAll(req.Body)
+ g, err := rm.buildTypeGraphRecursive(t, userVersion, make(map[reflect.Type]*TypeGraph))
if err != nil {
- return err
+ return nil, err
}
- header := req.Header.Clone()
+ // 3. Cache the result
+ rm.cache[key] = g
+ return g, nil
+}
+
+func (rm *RequestMigration) buildTypeGraphRecursive(t reflect.Type, userVersion *Version, visited map[reflect.Type]*TypeGraph) (*TypeGraph, error) {
+ if t.Kind() == reflect.Ptr {
+ t = t.Elem()
+ }
+
+ if g, ok := visited[t]; ok {
+ return g, nil
+ }
+
+ graph := &TypeGraph{
+ Type: t,
+ Fields: make(map[string]*TypeGraph),
+ }
+ visited[t] = graph
- for _, version := range m.versions {
- migrations, ok := m.migrations[version.String()]
- if !ok {
- return ErrInvalidVersion
+ graph.Migrations = rm.findMigrationsForType(t, userVersion)
+
+ if t.Kind() == reflect.Slice || t.Kind() == reflect.Array {
+ elemGraph, err := rm.buildTypeGraphRecursive(t.Elem(), userVersion, visited)
+ if err != nil {
+ return nil, err
}
- // skip initial version.
- if m.from.Equal(version) {
- continue
+ if elemGraph.HasMigrations() {
+ graph.Fields["__elem"] = elemGraph
}
+ }
- migration := m.retrieveHandlerRequestMigration(migrations, handler)
- if migration != nil {
- data, header, err = migration.Migrate(data, header)
+ if t.Kind() == reflect.Struct {
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+ fieldGraph, err := rm.buildTypeGraphRecursive(field.Type, userVersion, visited)
if err != nil {
- return err
+ return nil, err
+ }
+
+ if fieldGraph.HasMigrations() {
+ name := field.Name
+ if tag := field.Tag.Get("json"); tag != "" {
+ name = strings.Split(tag, ",")[0]
+ }
+ graph.Fields[name] = fieldGraph
}
}
}
- req.Header = header
+ return graph, nil
+}
- // set the body back for the rest of the middleware.
- req.Body = io.NopCloser(bytes.NewReader(data))
+func (g *TypeGraph) HasMigrations() bool {
+ if len(g.Migrations) > 0 {
+ return true
+ }
- return nil
+ for _, field := range g.Fields {
+ if field.HasMigrations() {
+ return true
+ }
+ }
+
+ return false
}
-func (m *migrator) applyResponseMigrations(r *http.Request, header http.Header, data []byte, handler string) ([]byte, error) {
- var err error
+func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any) error {
+ val := *data
+ if val == nil {
+ return nil
+ }
- for i := len(m.versions); i > 0; i-- {
- version := m.versions[i-1]
- migrations, ok := m.migrations[version.String()]
- if !ok {
- return nil, ErrServerError
+ switch v := val.(type) {
+ case map[string]interface{}:
+ for fieldName, fieldGraph := range graph.Fields {
+ if fieldName == "__elem" {
+ continue
+ }
+ fieldData, ok := v[fieldName]
+ if !ok || fieldData == nil {
+ continue
+ }
+ if err := rm.migrateForward(fieldGraph, &fieldData); err != nil {
+ return err
+ }
+ v[fieldName] = fieldData
}
+ case []interface{}:
+ elemGraph := graph.Fields["__elem"]
+ if elemGraph != nil {
+ for i := range v {
+ if err := rm.migrateForward(elemGraph, &v[i]); err != nil {
+ return err
+ }
+ }
+ }
+ }
- // skip initial version.
- if m.from.Equal(version) {
- return data, nil
+ for _, m := range graph.Migrations {
+ migratedData, err := m.MigrateForward(*data)
+ if err != nil {
+ return err
}
+ *data = migratedData
+ }
- migration := m.retrieveHandlerResponseMigration(migrations, handler)
- if migration != nil {
- data, _, err = migration.Migrate(data, header)
- if err != nil {
- return nil, ErrServerError
- }
+ return nil
+}
+
+func (rm *RequestMigration) migrateBackward(graph *TypeGraph, data *any) error {
+ if *data == nil {
+ return nil
+ }
+
+ for i := len(graph.Migrations) - 1; i >= 0; i-- {
+ m := graph.Migrations[i]
+ migratedData, err := m.MigrateBackward(*data)
+ if err != nil {
+ return err
}
+ *data = migratedData
+ }
+
+ val := *data
+ switch v := val.(type) {
+ case map[string]interface{}:
+ for fieldName, fieldGraph := range graph.Fields {
+ if fieldName == "__elem" {
+ continue
+ }
+ fieldData, ok := v[fieldName]
+ if !ok || fieldData == nil {
+ continue
+ }
+ if err := rm.migrateBackward(fieldGraph, &fieldData); err != nil {
+ return err
+ }
+ v[fieldName] = fieldData
+ }
+ case []interface{}:
+ elemGraph := graph.Fields["__elem"]
+ if elemGraph != nil {
+ for i := range v {
+ if err := rm.migrateBackward(elemGraph, &v[i]); err != nil {
+ return err
+ }
+ }
+ }
}
- return data, nil
+ return nil
}
-func (m *migrator) retrieveHandlerResponseMigration(migrations Migrations, handler string) Migration {
- return m.retrieveHandlerMigration(migrations, strings.Join([]string{handler, "response"}, ""))
+func Register[T any](rm *RequestMigration, version string, m TypeMigration) error {
+ t := reflect.TypeOf((*T)(nil)).Elem()
+ return rm.registerTypeMigration(version, t, m)
}
-func (m *migrator) retrieveHandlerRequestMigration(migrations Migrations, handler string) Migration {
- return m.retrieveHandlerMigration(migrations, strings.Join([]string{handler, "request"}, ""))
+func (rm *RequestMigration) registerTypeMigration(version string, t reflect.Type, m TypeMigration) error {
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+
+ if rm.migrations == nil {
+ rm.migrations = make(map[reflect.Type]map[string]TypeMigration)
+ }
+
+ // Check if this version is already known
+ versionKnown := false
+ for _, v := range rm.versions {
+ if v.Value == version {
+ versionKnown = true
+ break
+ }
+ }
+
+ if !versionKnown {
+ rm.versions = append(rm.versions, &Version{Format: rm.opts.VersionFormat, Value: version})
+
+ switch rm.opts.VersionFormat {
+ case SemverFormat:
+ sort.Slice(rm.versions, semVerSorter(rm.versions))
+ case DateFormat:
+ sort.Slice(rm.versions, dateVersionSorter(rm.versions))
+ default:
+ return ErrInvalidVersionFormat
+ }
+ }
+
+ // Internal Type-Centric Pivot: map[Type]map[Version]Migration
+ if _, ok := rm.migrations[t]; !ok {
+ rm.migrations[t] = make(map[string]TypeMigration)
+ }
+ rm.migrations[t][version] = m
+
+ return nil
}
-func (m *migrator) retrieveHandlerMigration(migrations Migrations, handler string) Migration {
- for _, migration := range migrations {
- var mv reflect.Value
+func (rm *RequestMigration) findMigrationsForType(t reflect.Type, userVersion *Version) []TypeMigration {
+ rm.mu.Lock()
+ defer rm.mu.Unlock()
+
+ var applicableMigrations []TypeMigration
- mv = reflect.ValueOf(migration)
+ typeHistory, ok := rm.migrations[t]
+ if !ok {
+ return nil
+ }
- if mv.Kind() == reflect.Ptr {
- mv = mv.Elem()
+ // rm.versions is sorted oldest to newest.
+ for _, v := range rm.versions {
+ if v.Equal(userVersion) || v.isOlderThan(userVersion) {
+ continue
}
- fName := strings.ToLower(mv.Type().Name())
- if strings.HasPrefix(fName, strings.ToLower(handler)) {
- return migration
+ if migration, ok := typeHistory[v.String()]; ok {
+ applicableMigrations = append(applicableMigrations, migration)
}
}
- return nil
+ return applicableMigrations
}
diff --git a/requestmigrations_test.go b/requestmigrations_test.go
index 1e757e1..3f911cd 100644
--- a/requestmigrations_test.go
+++ b/requestmigrations_test.go
@@ -1,22 +1,53 @@
package requestmigrations
import (
- "bytes"
"encoding/json"
- "errors"
- "io"
+ "fmt"
"net/http"
"net/http/httptest"
+ "reflect"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
-type user struct {
- Email string `json:"email"`
- FirstName string `json:"first_name"`
- LastName string `json:"last_name"`
+// v1 - 2023-02-01
+type profile struct {
+ Name string `json:"name"`
+ Address string `json:"address"`
+}
+
+// v2 - 2023-03-01
+type profilev2 struct {
+ FirstName string `json:"firstName"`
+ LastName string `json:"lastName"`
+ Address *address
+}
+
+type address struct {
+ StreetName string `json:"streetName"`
+ Country string `json:"country"`
+ State string `json:"state"`
+ Postcode string `json:"postCode"`
+}
+
+type addressMigration struct{}
+
+func (m *addressMigration) MigrateForward(data any) (any, error) {
+ return nil, nil
+}
+
+func (m *addressMigration) MigrateBackward(data any) (any, error) {
+ a, ok := data.(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("bad type")
+ }
+
+ addrString := fmt.Sprintf("%s %s %s %s", a["streetName"],
+ a["state"], a["country"], a["postCode"])
+
+ return []byte(addrString), nil
}
func newRequestMigration(t *testing.T) *RequestMigration {
@@ -34,310 +65,234 @@ func newRequestMigration(t *testing.T) *RequestMigration {
return rm
}
-func registerBasicMigrations(t *testing.T, rm *RequestMigration) {
- migrations := &MigrationStore{
- "2023-03-01": Migrations{
- &getUserResponseCombineNamesMigration{},
- &createUserRequestSplitNameMigration{},
- &createUserResponseCombineNamesMigration{},
- },
- }
+func registerVersions(t *testing.T, rm *RequestMigration) {
+ // Register migrations for version 2023-03-01
+ err := Register[address](rm, "2023-03-01", &addressMigration{})
- err := rm.RegisterMigrations(*migrations)
if err != nil {
- t.Error(err)
+ t.Fatal(err)
}
}
-type oldUser struct {
- Email string `json:"email"`
- FullName string `json:"full_name"`
-}
-
-type getUserResponseCombineNamesMigration struct{}
-
-func (c *getUserResponseCombineNamesMigration) Migrate(
- body []byte,
- h http.Header) ([]byte, http.Header, error) {
+func Test_Marshal(t *testing.T) {
+ rm := newRequestMigration(t)
+ registerVersions(t, rm)
- var newuser user
- err := json.Unmarshal(body, &newuser)
- if err != nil {
- return nil, nil, err
+ tests := map[string]struct {
+ assert require.ErrorAssertionFunc
+ }{
+ "no_transformation": {
+ assert: require.NoError,
+ },
}
- var user oldUser
- user.Email = newuser.Email
- user.FullName = strings.Join([]string{newuser.FirstName, newuser.LastName}, " ")
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/profile", strings.NewReader(""))
+ req.Header.Add("X-Test-Version", "2023-02-01")
+
+ pStruct := profilev2{
+ Address: &address{
+ State: "London",
+ Postcode: "CR0 1GB",
+ },
+ }
+ bytesW, err := rm.WithUserVersion(req).Marshal(&pStruct)
- body, err = json.Marshal(&user)
- if err != nil {
- return nil, nil, err
+ _ = bytesW
+ tc.assert(t, err)
+ })
}
-
- return body, h, nil
}
-type createUserRequestSplitNameMigration struct{}
-
-func (c *createUserRequestSplitNameMigration) Migrate(
- body []byte,
- h http.Header) ([]byte, http.Header, error) {
-
- var oUser oldUser
- err := json.Unmarshal(body, &oUser)
- if err != nil {
- return nil, nil, err
- }
+func Test_Unmarshal(t *testing.T) {}
- var nUser user
- nUser.Email = oUser.Email
+type AddressString string
- splitName := strings.Split(oUser.FullName, " ")
- nUser.FirstName = splitName[0]
- nUser.LastName = splitName[1]
+type addressStringMigration struct{}
- body, err = json.Marshal(&nUser)
- if err != nil {
- return nil, nil, err
+func (m *addressStringMigration) MigrateForward(data any) (any, error) {
+ s, ok := data.(string)
+ if !ok {
+ return nil, fmt.Errorf("bad type")
}
-
- return body, h, nil
+ return AddressString("Migrated: " + s), nil
}
-type createUserResponseCombineNamesMigration struct{}
-
-func (c *createUserResponseCombineNamesMigration) Migrate(
- body []byte,
- h http.Header) ([]byte, http.Header, error) {
-
- var newuser user
- err := json.Unmarshal(body, &newuser)
- if err != nil {
- return nil, nil, err
- }
-
- var user oldUser
- user.Email = newuser.Email
- user.FullName = strings.Join([]string{newuser.FirstName, newuser.LastName}, " ")
-
- body, err = json.Marshal(&user)
- if err != nil {
- return nil, nil, err
+func (m *addressStringMigration) MigrateBackward(data any) (any, error) {
+ s, ok := data.(string)
+ if !ok {
+ return nil, fmt.Errorf("bad type")
}
+ return "Backward: " + s, nil
+}
- return body, h, nil
+type CustomUser struct {
+ Address AddressString `json:"address"`
}
-func createUser(t *testing.T, rm *RequestMigration) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- err, vw, rollback := rm.Migrate(r, "createUser")
- if err != nil {
- t.Fatal(err)
- }
- defer rollback(w)
+func Test_CustomPrimitive(t *testing.T) {
+ opts := &RequestMigrationOptions{
+ VersionHeader: "X-Test-Version",
+ CurrentVersion: "2023-03-01",
+ VersionFormat: DateFormat,
+ }
+ rm, _ := NewRequestMigration(opts)
+ Register[AddressString](rm, "2023-03-01", &addressStringMigration{})
- payload, err := io.ReadAll(r.Body)
- if err != nil {
- t.Fatal(err)
- }
+ t.Run("Marshal custom primitive", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Add("X-Test-Version", "2023-02-01")
- var userObject user
- err = json.Unmarshal(payload, &userObject)
- if err != nil {
- t.Fatal(err)
- }
+ u := CustomUser{Address: "Main St"}
+ data, err := rm.WithUserVersion(req).Marshal(&u)
+ require.NoError(t, err)
- userObject = user{
- Email: userObject.Email,
- FirstName: userObject.FirstName,
- LastName: userObject.LastName,
- }
+ var res map[string]interface{}
+ json.Unmarshal(data, &res)
+ require.Equal(t, "Backward: Main St", res["address"])
+ })
- body, err := json.Marshal(userObject)
- if err != nil {
- t.Fatal(err)
- }
+ t.Run("Unmarshal custom primitive", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/", nil)
+ req.Header.Add("X-Test-Version", "2023-02-01")
- vw.Write(body)
+ jsonData := `{"address": "Main St"}`
+ var u CustomUser
+ err := rm.WithUserVersion(req).Unmarshal([]byte(jsonData), &u)
+ require.NoError(t, err)
+ require.Equal(t, AddressString("Migrated: Main St"), u.Address)
})
}
-func Test_VersionRequest(t *testing.T) {
- rm := newRequestMigration(t)
- registerBasicMigrations(t, rm)
-
- tests := map[string]struct {
- assert require.ErrorAssertionFunc
- body strings.Reader
- addHeader func(req *http.Request)
- parseResponse func(t *testing.T, data []byte) error
- }{
- "no_transformation": {
- assert: require.NoError,
- body: *strings.NewReader(`
- {
- "email": "engineering@getconvoy.io",
- "first_name": "Convoy",
- "last_name": "Engineering"
- }
- `),
- addHeader: func(req *http.Request) {
- req.Header.Add("X-Test-Version", "2023-03-01")
- },
- parseResponse: func(t *testing.T, data []byte) error {
- var newUser user
- err := json.Unmarshal(data, &newUser)
- if err != nil {
- return err
- }
-
- if isStringEmpty(newUser.FirstName) || isStringEmpty(newUser.LastName) {
- return errors.New("Firstname or Lastname is not present")
- }
-
- return nil
- },
- },
- "should_transform_request_payload": {
- assert: require.NoError,
- body: *strings.NewReader(`
- {
- "email": "engineering@getconvoy.io",
- "full_name": "Convoy Engineering"
- }
- `),
- parseResponse: func(t *testing.T, data []byte) error {
- var user oldUser
- err := json.Unmarshal(data, &user)
- if err != nil {
- return err
- }
-
- if isStringEmpty(user.FullName) {
- return errors.New("Fullname is not present")
- }
-
- return nil
- },
- },
- }
-
- for name, tc := range tests {
- t.Run(name, func(t *testing.T) {
- req := httptest.NewRequest(http.MethodPost, "/users", &tc.body)
-
- if tc.addHeader != nil {
- tc.addHeader(req)
- }
-
- rr := httptest.NewRecorder()
-
- createUserHandler := createUser(t, rm)
- createUserHandler.ServeHTTP(rr, req)
+type CyclicUser struct {
+ ID string `json:"id"`
+ Workspace *CyclicWorkspace `json:"workspace"`
+}
- // Inspect response to determine it worked.
- data, err := io.ReadAll(bytes.NewReader(rr.Body.Bytes()))
- if err != nil {
- t.Error(err)
- }
+type CyclicWorkspace struct {
+ ID string `json:"id"`
+ Users []*CyclicUser `json:"users"`
+}
- // Assert.
- tc.assert(t, tc.parseResponse(t, data))
- })
+func Test_Cycles(t *testing.T) {
+ opts := &RequestMigrationOptions{
+ VersionHeader: "X-Test-Version",
+ CurrentVersion: "2023-03-01",
+ VersionFormat: DateFormat,
}
+ rm, _ := NewRequestMigration(opts)
+ t.Run("Build graph with cycles", func(t *testing.T) {
+ _, err := rm.buildTypeGraph(reflect.TypeOf(CyclicUser{}), &Version{Format: DateFormat, Value: "2023-01-01"})
+ require.NoError(t, err)
+ })
}
-func getUser(t *testing.T, rm *RequestMigration) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- err, vw, rollback := rm.Migrate(r, "getUser")
- if err != nil {
- t.Fatal(err)
- }
- defer rollback(w)
+func Test_RootSlice(t *testing.T) {
+ opts := &RequestMigrationOptions{
+ VersionHeader: "X-Test-Version",
+ CurrentVersion: "2023-03-01",
+ VersionFormat: DateFormat,
+ }
+ rm, _ := NewRequestMigration(opts)
+ Register[AddressString](rm, "2023-03-01", &addressStringMigration{})
- user := &user{
- Email: "engineering@getconvoy.io",
- FirstName: "Convoy",
- LastName: "Engineering",
- }
+ t.Run("Marshal root slice", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Add("X-Test-Version", "2023-02-01")
- body, err := json.Marshal(user)
- if err != nil {
- t.Fatal(err)
+ users := []CustomUser{
+ {Address: "Main St"},
+ {Address: "Second St"},
}
+ data, err := rm.WithUserVersion(req).Marshal(&users)
+ require.NoError(t, err)
+
+ var res []map[string]interface{}
+ json.Unmarshal(data, &res)
+ require.Len(t, res, 2)
+ require.Equal(t, "Backward: Main St", res[0]["address"])
+ require.Equal(t, "Backward: Second St", res[1]["address"])
+ })
- vw.Write(body)
+ t.Run("Unmarshal root slice", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/", nil)
+ req.Header.Add("X-Test-Version", "2023-02-01")
+
+ jsonData := `[{"address": "Main St"}, {"address": "Second St"}]`
+ var users []CustomUser
+ err := rm.WithUserVersion(req).Unmarshal([]byte(jsonData), &users)
+ require.NoError(t, err)
+ require.Len(t, users, 2)
+ require.Equal(t, AddressString("Migrated: Main St"), users[0].Address)
+ require.Equal(t, AddressString("Migrated: Second St"), users[1].Address)
})
}
-func Test_VersionResponse(t *testing.T) {
- rm := newRequestMigration(t)
- registerBasicMigrations(t, rm)
+type chainMigrationV2 struct{}
- tests := map[string]struct {
- assert require.ErrorAssertionFunc
- addHeader func(req *http.Request)
- parseResponse func(t *testing.T, data []byte) error
- }{
- "no_transformation": {
- assert: require.NoError,
- addHeader: func(req *http.Request) {
- req.Header.Add("X-Test-Version", "2023-03-01")
- },
- parseResponse: func(t *testing.T, data []byte) error {
- var newUser user
- err := json.Unmarshal(data, &newUser)
- if err != nil {
- return err
- }
-
- if isStringEmpty(newUser.FirstName) || isStringEmpty(newUser.LastName) {
- return errors.New("Firstname or Lastname is not present")
- }
-
- return nil
- },
- },
- "should_transform_response_payload": {
- assert: require.NoError,
- parseResponse: func(t *testing.T, data []byte) error {
- var user oldUser
- err := json.Unmarshal(data, &user)
- if err != nil {
- return err
- }
-
- if isStringEmpty(user.FullName) {
- return errors.New("Fullname is not present")
- }
-
- return nil
- },
- },
+func (m *chainMigrationV2) MigrateForward(data any) (any, error) {
+ s := data.(string)
+ return s + " -> v2", nil
+}
+func (m *chainMigrationV2) MigrateBackward(data any) (any, error) {
+ s := data.(string)
+ return s + " -> v1", nil
+}
+
+type chainMigrationV3 struct{}
+
+func (m *chainMigrationV3) MigrateForward(data any) (any, error) {
+ s := data.(string)
+ return s + " -> v3", nil
+}
+func (m *chainMigrationV3) MigrateBackward(data any) (any, error) {
+ s := data.(string)
+ return s + " -> v2", nil
+}
+
+func Test_VersionChain(t *testing.T) {
+ opts := &RequestMigrationOptions{
+ VersionHeader: "X-Test-Version",
+ CurrentVersion: "2023-03-01",
+ VersionFormat: DateFormat,
}
+ rm, _ := NewRequestMigration(opts)
- for name, tc := range tests {
- t.Run(name, func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/users", strings.NewReader(""))
- if tc.addHeader != nil {
- tc.addHeader(req)
- }
+ // v1: 2023-01-01 (initial version, no migrations)
+ // v2: 2023-02-01
+ Register[AddressString](rm, "2023-02-01", &chainMigrationV2{})
+ // v3: 2023-03-01
+ Register[AddressString](rm, "2023-03-01", &chainMigrationV3{})
- rr := httptest.NewRecorder()
+ t.Run("Marshal chain v1 to v3", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Add("X-Test-Version", "2023-01-01")
- getUserHandler := getUser(t, rm)
- getUserHandler.ServeHTTP(rr, req)
+ type User struct {
+ Address AddressString `json:"address"`
+ }
+ u := User{Address: "start"}
+ data, err := rm.WithUserVersion(req).Marshal(&u)
+ require.NoError(t, err)
- // Inspect response to determine it worked.
- data, err := io.ReadAll(bytes.NewReader(rr.Body.Bytes()))
- if err != nil {
- t.Error(err)
- }
+ var res map[string]interface{}
+ json.Unmarshal(data, &res)
+ require.Equal(t, "start -> v2 -> v1", res["address"])
+ })
- // Assert.
- tc.assert(t, tc.parseResponse(t, data))
- })
- }
+ t.Run("Unmarshal chain v1 to v3", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/", nil)
+ req.Header.Add("X-Test-Version", "2023-01-01")
+
+ type User struct {
+ Address AddressString `json:"address"`
+ }
+ jsonData := `{"address": "start"}`
+ var u User
+ err := rm.WithUserVersion(req).Unmarshal([]byte(jsonData), &u)
+ require.NoError(t, err)
+ require.Equal(t, AddressString("start -> v2 -> v3"), u.Address)
+ })
}
diff --git a/response.go b/response.go
deleted file mode 100644
index 6341afa..0000000
--- a/response.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package requestmigrations
-
-import "net/http"
-
-type response struct {
- body []byte
- header http.Header
- statusCode int
-}
-
-func (r *response) Write(body []byte) {
- r.body = body
-}
-
-func (r *response) Header(header http.Header) {
- r.header = header
-}
-
-func (r *response) SetHeader(statusCode int) {
- r.statusCode = statusCode
-}
diff --git a/version.go b/version.go
index 4156606..760874f 100644
--- a/version.go
+++ b/version.go
@@ -60,6 +60,38 @@ func (v *Version) Equal(vv *Version) bool {
return false
}
+
+func (v *Version) isOlderThan(vv *Version) bool {
+ switch v.Format {
+ case SemverFormat:
+ sv, err := semver.NewVersion(v.Value.(string))
+ if err != nil {
+ return false
+ }
+
+ svv, err := semver.NewVersion(vv.Value.(string))
+ if err != nil {
+ return false
+ }
+
+ return sv.LessThan(svv)
+
+ case DateFormat:
+ tv, err := time.Parse(time.DateOnly, v.Value.(string))
+ if err != nil {
+ return false
+ }
+
+ tvv, err := time.Parse(time.DateOnly, vv.Value.(string))
+ if err != nil {
+ return false
+ }
+
+ return tv.Before(tvv)
+ }
+
+ return false
+}
func (v *Version) String() string {
return v.Value.(string)
}