From fad93a827c38c372e2e8d89cf39fdb6839134dac Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Mon, 12 Jan 2026 20:31:18 +0000 Subject: [PATCH 1/5] feat: working implementation of v2 --- requestmigrations.go | 467 ++++++++++++++++++++---------------- requestmigrations_test.go | 481 +++++++++++++++++--------------------- version.go | 32 +++ 3 files changed, 518 insertions(+), 462 deletions(-) diff --git a/requestmigrations.go b/requestmigrations.go index 14a2968..d5cac9a 100644 --- a/requestmigrations.go +++ b/requestmigrations.go @@ -1,9 +1,8 @@ package requestmigrations import ( - "bytes" + "encoding/json" "errors" - "io" "net/http" "reflect" "sort" @@ -28,24 +27,6 @@ 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. @@ -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 @@ -79,7 +58,7 @@ type RequestMigration struct { iv string mu sync.Mutex - migrations MigrationStore + migrations map[string]map[reflect.Type]TypeMigration // version -> type -> migration } func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, error) { @@ -103,118 +82,17 @@ func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, erro iv = "v0" } - migrations := MigrationStore{ - iv: []Migration{}, - } - var versions []*Version versions = append(versions, &Version{Format: opts.VersionFormat, Value: iv}) return &RequestMigration{ - opts: opts, - metric: me, - iv: iv, - versions: versions, - migrations: migrations, + opts: opts, + metric: me, + iv: iv, + versions: versions, }, 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) @@ -300,125 +178,316 @@ func (rm *RequestMigration) writeResponseToClient(w http.ResponseWriter, res *re return nil } -type migrator struct { - to *Version - from *Version - versions []*Version - migrations MigrationStore +// VersionedRequestMigration is a wrapper that holds context for a specific request +type VersionedRequestMigration struct { + rm *RequestMigration + request *http.Request // needed to extract user version } -func Newmigrator(from, to *Version, avs []*Version, migrations MigrationStore) (*migrator, error) { - if !from.IsValid() || !to.IsValid() { - return nil, ErrInvalidVersion +func (rm *RequestMigration) WithUserVersion(r *http.Request) *VersionedRequestMigration { + return &VersionedRequestMigration{ + rm: rm, + request: r, } +} - var versions []*Version - for i, v := range avs { - if v.Equal(from) { - versions = avs[i:] - break - } +func (vrm *VersionedRequestMigration) Marshal(v interface{}) ([]byte, error) { + userVersion, err := vrm.rm.getUserVersion(vrm.request) + if err != nil { + return nil, err } - return &migrator{ - to: to, - from: from, - versions: versions, - migrations: migrations, - }, nil + graph, err := vrm.rm.buildTypeGraph(reflect.TypeOf(v), userVersion) + if err != nil { + return nil, err + } + + if !graph.HasMigrations() { + return json.Marshal(v) + } + + 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 := vrm.rm.migrateBackward(graph, &intermediate, userVersion); err != nil { + return nil, err + } + + return json.Marshal(intermediate) } -func (m *migrator) applyRequestMigrations(req *http.Request, handler string) error { - if m.versions == nil { - return nil +func (vrm *VersionedRequestMigration) Unmarshal(data []byte, v interface{}) error { + userVersion, err := vrm.rm.getUserVersion(vrm.request) + if err != nil { + return err + } + + t := reflect.TypeOf(v) + if t.Kind() != reflect.Ptr { + return errors.New("v must be a pointer") } - data, err := io.ReadAll(req.Body) + graph, err := vrm.rm.buildTypeGraph(t, userVersion) if err != nil { return err } - header := req.Header.Clone() + if !graph.HasMigrations() { + return json.Unmarshal(data, v) + } - for _, version := range m.versions { - migrations, ok := m.migrations[version.String()] - if !ok { - return ErrInvalidVersion + var intermediate any + if err := json.Unmarshal(data, &intermediate); err != nil { + return err + } + + if err := vrm.rm.migrateForward(graph, &intermediate, userVersion); err != nil { + return err + } + + data, err = json.Marshal(intermediate) + if err != nil { + return err + } + + return json.Unmarshal(data, v) +} + +// 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) +} + +// 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) { + return rm.buildTypeGraphRecursive(t, userVersion, make(map[reflect.Type]*TypeGraph)) +} + +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 + + 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, fromVersion *Version) 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, fromVersion); 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], fromVersion); 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, toVersion *Version) 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, toVersion); 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], toVersion); 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[string]map[reflect.Type]TypeMigration) + } + + if _, ok := rm.migrations[version]; !ok { + rm.migrations[version] = make(map[reflect.Type]TypeMigration) + 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 + } + } + + rm.migrations[version][t] = 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() - mv = reflect.ValueOf(migration) + var applicableMigrations []TypeMigration - if mv.Kind() == reflect.Ptr { - mv = mv.Elem() + 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 + typeMigrations, ok := rm.migrations[v.String()] + if !ok { + continue + } + + if migration, ok := typeMigrations[t]; 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/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) } From cf2c8eb927dbf4b79938979e6f37e414881bae95 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Mon, 12 Jan 2026 20:38:25 +0000 Subject: [PATCH 2/5] chore: update action files --- .github/workflows/go.yml | 6 +- .github/workflows/linter.yml | 28 ++-- .golangci.yml | 292 +++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 .golangci.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 5f7fdb9..3647450 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -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 From f7cd72fc12e921c318d6638b658750a91f6f8af8 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Mon, 12 Jan 2026 20:50:12 +0000 Subject: [PATCH 3/5] chore: run linter --- .github/workflows/go.yml | 2 +- {example => examples}/advanced/main.go | 0 {example => examples}/basic/call_api.sh | 0 {example => examples}/basic/go.mod | 2 +- {example => examples}/basic/go.sum | 0 {example => examples}/basic/helper/helper.go | 0 {example => examples}/basic/main.go | 0 {example => examples}/basic/requestmigrations.go | 0 {example => examples}/basic/store.go | 0 .../basic/v20230401/requestmigrations.go | 0 .../basic/v20230501/requestmigrations.go | 0 go.mod | 2 +- requestmigrations.go | 10 +++++----- 13 files changed, 8 insertions(+), 8 deletions(-) rename {example => examples}/advanced/main.go (100%) rename {example => examples}/basic/call_api.sh (100%) rename {example => examples}/basic/go.mod (98%) rename {example => examples}/basic/go.sum (100%) rename {example => examples}/basic/helper/helper.go (100%) rename {example => examples}/basic/main.go (100%) rename {example => examples}/basic/requestmigrations.go (100%) rename {example => examples}/basic/store.go (100%) rename {example => examples}/basic/v20230401/requestmigrations.go (100%) rename {example => examples}/basic/v20230501/requestmigrations.go (100%) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 3647450..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: diff --git a/example/advanced/main.go b/examples/advanced/main.go similarity index 100% rename from example/advanced/main.go rename to examples/advanced/main.go 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 98% rename from example/basic/go.mod rename to examples/basic/go.mod index 19f32f4..34f94ba 100644 --- a/example/basic/go.mod +++ b/examples/basic/go.mod @@ -1,6 +1,6 @@ module basicexample -go 1.18 +go 1.24 require ( github.com/gorilla/mux v1.8.0 diff --git a/example/basic/go.sum b/examples/basic/go.sum similarity index 100% rename from example/basic/go.sum rename to examples/basic/go.sum diff --git a/example/basic/helper/helper.go b/examples/basic/helper/helper.go similarity index 100% rename from example/basic/helper/helper.go rename to examples/basic/helper/helper.go diff --git a/example/basic/main.go b/examples/basic/main.go similarity index 100% rename from example/basic/main.go rename to examples/basic/main.go diff --git a/example/basic/requestmigrations.go b/examples/basic/requestmigrations.go similarity index 100% rename from example/basic/requestmigrations.go rename to examples/basic/requestmigrations.go diff --git a/example/basic/store.go b/examples/basic/store.go similarity index 100% rename from example/basic/store.go rename to examples/basic/store.go diff --git a/example/basic/v20230401/requestmigrations.go b/examples/basic/v20230401/requestmigrations.go similarity index 100% rename from example/basic/v20230401/requestmigrations.go rename to examples/basic/v20230401/requestmigrations.go diff --git a/example/basic/v20230501/requestmigrations.go b/examples/basic/v20230501/requestmigrations.go similarity index 100% rename from example/basic/v20230501/requestmigrations.go rename to examples/basic/v20230501/requestmigrations.go diff --git a/go.mod b/go.mod index cfa0516..9702937 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/subomi/requestmigrations -go 1.20 +go 1.24 require ( github.com/Masterminds/semver/v3 v3.2.1 diff --git a/requestmigrations.go b/requestmigrations.go index d5cac9a..ba4d3a0 100644 --- a/requestmigrations.go +++ b/requestmigrations.go @@ -33,7 +33,7 @@ type GetUserVersionFunc func(req *http.Request) (string, error) 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 @@ -76,9 +76,10 @@ 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" } @@ -94,8 +95,7 @@ func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, erro } func (rm *RequestMigration) getUserVersion(req *http.Request) (*Version, error) { - var vh string - vh = req.Header.Get(rm.opts.VersionHeader) + var vh string = req.Header.Get(rm.opts.VersionHeader) if !isStringEmpty(vh) { return &Version{ From abe6b0294662863a5571f714763b338afef3ee6a Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Mon, 12 Jan 2026 21:32:33 +0000 Subject: [PATCH 4/5] chore: updated examples --- README.md | 99 ++------- examples/advanced/go.mod | 21 ++ examples/advanced/go.sum | 32 +++ examples/advanced/main.go | 146 ++++++++++++- examples/basic/go.mod | 5 +- examples/basic/go.sum | 10 +- examples/basic/helper/helper.go | 4 + examples/basic/main.go | 63 +++--- examples/basic/requestmigrations.go | 153 ++++--------- examples/basic/store.go | 4 +- examples/basic/v20230401/requestmigrations.go | 66 ------ examples/basic/v20230501/requestmigrations.go | 72 ------ go.mod | 2 +- go.sum | 4 + requestmigrations.go | 205 ++++++++++++------ response.go | 21 -- 16 files changed, 453 insertions(+), 454 deletions(-) create mode 100644 examples/advanced/go.mod create mode 100644 examples/advanced/go.sum delete mode 100644 examples/basic/v20230401/requestmigrations.go delete mode 100644 examples/basic/v20230501/requestmigrations.go delete mode 100644 response.go diff --git a/README.md b/README.md index 05dbe5c..7b3b8f5 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,40 @@ -# requestmigrations
[![Go Reference](https://pkg.go.dev/badge/github.com/subomi/requestmigrations.svg)](https://pkg.go.dev/github.com/subomi/requestmigrations) +# requestmigrations
[![Go Reference](https://pkg.go.dev/badge/github.com/subomi/requestmigrations/v2.svg)](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/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 index ad14291..bc633c2 100644 --- a/examples/advanced/main.go +++ b/examples/advanced/main.go @@ -1,7 +1,149 @@ package main -import "fmt" +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() { - fmt.Println("TODO: Write a more complex example") + // 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) + } + + fmt.Printf("Unmarshaled from v2023-01-01 to Current:\n") + fmt.Printf("User Username: %s\n", incoming.Users[0].Username) + fmt.Printf("Project Count: %d\n", len(incoming.Projects)) + fmt.Printf("Project 'p1' Name: %s\n", incoming.Projects["p1"].Name) } diff --git a/examples/basic/go.mod b/examples/basic/go.mod index 34f94ba..75493a7 100644 --- a/examples/basic/go.mod +++ b/examples/basic/go.mod @@ -5,7 +5,7 @@ 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/examples/basic/go.sum b/examples/basic/go.sum index 10a4314..f1f035d 100644 --- a/examples/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/examples/basic/helper/helper.go b/examples/basic/helper/helper.go index 6ee8cef..86cf060 100644 --- a/examples/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/examples/basic/main.go b/examples/basic/main.go index 6a4dc09..f88fcf0 100644 --- a/examples/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 index a9f6dab..16684ab 100644 --- a/examples/basic/requestmigrations.go +++ b/examples/basic/requestmigrations.go @@ -1,131 +1,66 @@ package main import ( - "basicexample/helper" - "encoding/json" - "net/http" - "net/url" "strings" - "time" ) -// Migrations -type ListUserResponseMigration struct{} +// UserMigration handles migrations for the User type +type UserMigration 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) +func (m *UserMigration) MigrateForward(data any) (any, error) { + d, ok := data.(map[string]any) + if !ok { + return data, nil } - body, err = helper.GenerateSuccessResponse(&newUsers, "users retrieved successfully") - if err != nil { - return nil, nil, err + // 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 body, h, nil + return d, 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 { +func (m *UserMigration) MigrateBackward(data any) (any, error) { + d, ok := data.(map[string]any) + if !ok { + return data, nil + } - isUserPath := url.Path == "/users" - isGetMethod := method == http.MethodGet - isValidType := isReq == false + // 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 isUserPath && isGetMethod && isValidType + return d, nil } -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 - } +// ProfileMigration handles migrations for the profile type if needed +type ProfileMigration struct{} - 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) +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 +} - body, err = helper.GenerateSuccessResponse(&newUsers, "users retrieved successfully") - if err != nil { - return nil, nil, err +func (m *ProfileMigration) MigrateBackward(data any) (any, error) { + d, ok := data.(map[string]any) + if !ok { + return data, nil } - - return body, h, nil + // In older versions, profile was just the UID string + return d["uid"], nil } diff --git a/examples/basic/store.go b/examples/basic/store.go index a5c05fd..49481bd 100644 --- a/examples/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/examples/basic/v20230401/requestmigrations.go b/examples/basic/v20230401/requestmigrations.go deleted file mode 100644 index 29066b2..0000000 --- a/examples/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/examples/basic/v20230501/requestmigrations.go b/examples/basic/v20230501/requestmigrations.go deleted file mode 100644 index 75be5c2..0000000 --- a/examples/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/go.mod b/go.mod index 9702937..ac5a29b 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/subomi/requestmigrations +module github.com/subomi/requestmigrations/v2 go 1.24 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 ba4d3a0..000719f 100644 --- a/requestmigrations.go +++ b/requestmigrations.go @@ -57,8 +57,18 @@ type RequestMigration struct { metric *prometheus.HistogramVec iv string - mu sync.Mutex - migrations map[string]map[reflect.Type]TypeMigration // version -> type -> migration + 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) { @@ -83,19 +93,23 @@ func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, erro iv = "v0" } - var versions []*Version + versions := make([]*Version, 0, 1) versions = append(versions, &Version{Format: opts.VersionFormat, Value: iv}) return &RequestMigration{ - opts: opts, - metric: me, - iv: iv, - versions: versions, + opts: opts, + metric: me, + iv: iv, + versions: versions, + 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) getUserVersion(req *http.Request) (*Version, error) { - var vh string = req.Header.Get(rm.opts.VersionHeader) + var vh = req.Header.Get(rm.opts.VersionHeader) if !isStringEmpty(vh) { return &Version{ @@ -164,40 +178,30 @@ 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) - } - - _, err := w.Write(res.body) - if err != nil { - return err - } - - // TODO(subomi): log bytesWritten - return nil +func (rm *RequestMigration) WithUserVersion(r *http.Request) *RequestMigration { + newRM := *rm + newRM.request = r + return &newRM } -// VersionedRequestMigration is a wrapper that holds context for a specific request -type VersionedRequestMigration struct { - rm *RequestMigration - request *http.Request // needed to extract user version -} +func (rm *RequestMigration) Marshal(v interface{}) ([]byte, error) { + startTime := time.Now() -func (rm *RequestMigration) WithUserVersion(r *http.Request) *VersionedRequestMigration { - return &VersionedRequestMigration{ - rm: rm, - request: r, - } -} + var ( + err error + userVersion *Version + ) -func (vrm *VersionedRequestMigration) Marshal(v interface{}) ([]byte, error) { - userVersion, err := vrm.rm.getUserVersion(vrm.request) - if err != nil { - return nil, err + if rm.request != nil { + userVersion, err = rm.getUserVersion(rm.request) + if err != nil { + return nil, err + } + } else { + userVersion = rm.getCurrentVersion() } - graph, err := vrm.rm.buildTypeGraph(reflect.TypeOf(v), userVersion) + graph, err := rm.buildTypeGraph(reflect.TypeOf(v), userVersion) if err != nil { return nil, err } @@ -206,6 +210,8 @@ func (vrm *VersionedRequestMigration) Marshal(v interface{}) ([]byte, error) { return json.Marshal(v) } + currentVersion := rm.getCurrentVersion() + data, err := json.Marshal(v) if err != nil { return nil, err @@ -216,17 +222,36 @@ func (vrm *VersionedRequestMigration) Marshal(v interface{}) ([]byte, error) { return nil, err } - if err := vrm.rm.migrateBackward(graph, &intermediate, userVersion); err != nil { + if err := rm.migrateBackward(graph, &intermediate); err != nil { return nil, err } - return json.Marshal(intermediate) + 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 (vrm *VersionedRequestMigration) Unmarshal(data []byte, v interface{}) error { - userVersion, err := vrm.rm.getUserVersion(vrm.request) - if err != nil { - return err +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) @@ -234,7 +259,7 @@ func (vrm *VersionedRequestMigration) Unmarshal(data []byte, v interface{}) erro return errors.New("v must be a pointer") } - graph, err := vrm.rm.buildTypeGraph(t, userVersion) + graph, err := rm.buildTypeGraph(t, userVersion) if err != nil { return err } @@ -243,12 +268,14 @@ func (vrm *VersionedRequestMigration) Unmarshal(data []byte, v interface{}) erro return json.Unmarshal(data, v) } + currentVersion := rm.getCurrentVersion() + var intermediate any if err := json.Unmarshal(data, &intermediate); err != nil { return err } - if err := vrm.rm.migrateForward(graph, &intermediate, userVersion); err != nil { + if err := rm.migrateForward(graph, &intermediate); err != nil { return err } @@ -257,7 +284,14 @@ func (vrm *VersionedRequestMigration) Unmarshal(data []byte, v interface{}) erro return err } - return json.Unmarshal(data, v) + 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 } // TypeMigration defines how to migrate a specific type @@ -283,7 +317,40 @@ type TypeGraph struct { } func (rm *RequestMigration) buildTypeGraph(t reflect.Type, userVersion *Version) (*TypeGraph, error) { - return rm.buildTypeGraphRecursive(t, userVersion, make(map[reflect.Type]*TypeGraph)) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + key := graphCacheKey{ + t: t, + version: userVersion.String(), + } + + // 1. Check cache (O(1)) + rm.cacheMu.RLock() + if g, ok := rm.cache[key]; ok { + rm.cacheMu.RUnlock() + return g, nil + } + rm.cacheMu.RUnlock() + + // 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 + } + + g, err := rm.buildTypeGraphRecursive(t, userVersion, make(map[reflect.Type]*TypeGraph)) + if err != nil { + return nil, err + } + + // 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) { @@ -349,7 +416,7 @@ func (g *TypeGraph) HasMigrations() bool { return false } -func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any, fromVersion *Version) error { +func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any) error { val := *data if val == nil { return nil @@ -365,7 +432,7 @@ func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any, fromVers if !ok || fieldData == nil { continue } - if err := rm.migrateForward(fieldGraph, &fieldData, fromVersion); err != nil { + if err := rm.migrateForward(fieldGraph, &fieldData); err != nil { return err } v[fieldName] = fieldData @@ -374,7 +441,7 @@ func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any, fromVers elemGraph := graph.Fields["__elem"] if elemGraph != nil { for i := range v { - if err := rm.migrateForward(elemGraph, &v[i], fromVersion); err != nil { + if err := rm.migrateForward(elemGraph, &v[i]); err != nil { return err } } @@ -392,7 +459,7 @@ func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any, fromVers return nil } -func (rm *RequestMigration) migrateBackward(graph *TypeGraph, data *any, toVersion *Version) error { +func (rm *RequestMigration) migrateBackward(graph *TypeGraph, data *any) error { if *data == nil { return nil } @@ -418,7 +485,7 @@ func (rm *RequestMigration) migrateBackward(graph *TypeGraph, data *any, toVersi if !ok || fieldData == nil { continue } - if err := rm.migrateBackward(fieldGraph, &fieldData, toVersion); err != nil { + if err := rm.migrateBackward(fieldGraph, &fieldData); err != nil { return err } v[fieldName] = fieldData @@ -427,7 +494,7 @@ func (rm *RequestMigration) migrateBackward(graph *TypeGraph, data *any, toVersi elemGraph := graph.Fields["__elem"] if elemGraph != nil { for i := range v { - if err := rm.migrateBackward(elemGraph, &v[i], toVersion); err != nil { + if err := rm.migrateBackward(elemGraph, &v[i]); err != nil { return err } } @@ -447,11 +514,19 @@ func (rm *RequestMigration) registerTypeMigration(version string, t reflect.Type defer rm.mu.Unlock() if rm.migrations == nil { - rm.migrations = make(map[string]map[reflect.Type]TypeMigration) + 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 _, ok := rm.migrations[version]; !ok { - rm.migrations[version] = make(map[reflect.Type]TypeMigration) + if !versionKnown { rm.versions = append(rm.versions, &Version{Format: rm.opts.VersionFormat, Value: version}) switch rm.opts.VersionFormat { @@ -464,7 +539,12 @@ func (rm *RequestMigration) registerTypeMigration(version string, t reflect.Type } } - rm.migrations[version][t] = m + // 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 } @@ -474,17 +554,18 @@ func (rm *RequestMigration) findMigrationsForType(t reflect.Type, userVersion *V var applicableMigrations []TypeMigration + typeHistory, ok := rm.migrations[t] + if !ok { + return nil + } + + // rm.versions is sorted oldest to newest. for _, v := range rm.versions { if v.Equal(userVersion) || v.isOlderThan(userVersion) { continue } - typeMigrations, ok := rm.migrations[v.String()] - if !ok { - continue - } - - if migration, ok := typeMigrations[t]; ok { + if migration, ok := typeHistory[v.String()]; ok { applicableMigrations = append(applicableMigrations, migration) } } 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 -} From bb7ef5deac4303aa2c8c0c03217be4f1597659b0 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Mon, 12 Jan 2026 21:39:35 +0000 Subject: [PATCH 5/5] chore: updated examples --- examples/advanced/main.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/advanced/main.go b/examples/advanced/main.go index bc633c2..88d055b 100644 --- a/examples/advanced/main.go +++ b/examples/advanced/main.go @@ -142,8 +142,6 @@ func main() { log.Fatal(err) } - fmt.Printf("Unmarshaled from v2023-01-01 to Current:\n") - fmt.Printf("User Username: %s\n", incoming.Users[0].Username) - fmt.Printf("Project Count: %d\n", len(incoming.Projects)) - fmt.Printf("Project 'p1' Name: %s\n", incoming.Projects["p1"].Name) + incomingJSON, _ := json.MarshalIndent(incoming, "", " ") + fmt.Printf("Unmarshaled from v2023-01-01 to Current:\n%s\n", string(incomingJSON)) }