From 405fa50de874cb4d55e0428efbd7e28944d933f1 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Sun, 18 Jan 2026 15:29:01 -0800 Subject: [PATCH] feat: context propagation and dynamic type analysis --- docs/bulk_registration.md | 254 ++++++++++++++ docs/context_propagation.md | 499 ++++++++++++++++++++++++++++ docs/runtime_type_detection.md | 366 ++++++++++++++++++++ examples/advanced/main.go | 18 +- examples/basic/main.go | 18 +- examples/basic/requestmigrations.go | 9 +- requestmigrations.go | 286 +++++++++++----- requestmigrations_test.go | 402 +++++++++++++++++++++- util.go | 14 +- 9 files changed, 1752 insertions(+), 114 deletions(-) create mode 100644 docs/bulk_registration.md create mode 100644 docs/context_propagation.md create mode 100644 docs/runtime_type_detection.md diff --git a/docs/bulk_registration.md b/docs/bulk_registration.md new file mode 100644 index 0000000..e1a2e3f --- /dev/null +++ b/docs/bulk_registration.md @@ -0,0 +1,254 @@ +# Bulk Registration API Design + +**Status:** Proposed +**Author:** Subomi Oluwalana +**Date:** January 2026 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Problem](#problem) +3. [Solution](#solution) +4. [API Design](#api-design) +5. [Internal Implementation](#internal-implementation) +6. [Usage Examples](#usage-examples) +7. [Error Handling](#error-handling) +8. [Design Decisions](#design-decisions) +9. [Implementation Plan](#implementation-plan) + +--- + +## Overview + +This document describes a cleaner way to register multiple type migrations for a single version atomically. + +--- + +## Problem + +Currently, registering multiple migrations for a single version requires repetitive calls: + +```go +rms.Register[User](rm, "2024-01-01", &UserMigration{}) +rms.Register[Address](rm, "2024-01-01", &AddressMigration{}) +rms.Register[Order](rm, "2024-01-01", &OrderMigration{}) +rms.Register[Payment](rm, "2024-01-01", &PaymentMigration{}) +``` + +This is verbose and error-prone (easy to typo the version string). + +--- + +## Solution: Struct-Based Bulk Registration + +Provide a `RegisterVersion` function that accepts a structured definition of all migrations for a version. + +--- + +## API Design + +### TypedMigration Struct + +```go +// TypedMigration pairs a Go type with its migration implementation. +// Used with RegisterVersion for bulk registration. +type TypedMigration struct { + // Type is an instance of the Go type this migration handles. + // Only the type information is used; the value is ignored. + // Example: User{} or (*User)(nil) + Type any + + // Migration is the TypeMigration implementation for this type. + Migration TypeMigration +} +``` + +### VersionMigrations Struct + +```go +// VersionMigrations defines all type migrations for a specific API version. +type VersionMigrations struct { + // Version is the API version string (e.g., "2024-01-01" or "v2.0.0"). + Version string + + // Migrations is the list of type migrations for this version. + Migrations []TypedMigration +} +``` + +### RegisterVersion Function + +```go +// RegisterVersion registers all type migrations for a specific version atomically. +// If any migration fails to register, no migrations are registered and an error is returned. +// +// Example: +// +// err := rms.RegisterVersion(rm, &rms.VersionMigrations{ +// Version: "2024-01-01", +// Migrations: []rms.TypedMigration{ +// {Type: User{}, Migration: &UserMigration{}}, +// {Type: Address{}, Migration: &AddressMigration{}}, +// {Type: Order{}, Migration: &OrderMigration{}}, +// }, +// }) +// +func RegisterVersion(rm *RequestMigration, vm *VersionMigrations) error +``` + +--- + +## Internal Implementation + +```go +func RegisterVersion(rm *RequestMigration, vm *VersionMigrations) error { + if vm == nil { + return errors.New("version migrations cannot be nil") + } + + if vm.Version == "" { + return errors.New("version cannot be empty") + } + + if len(vm.Migrations) == 0 { + return errors.New("migrations list cannot be empty") + } + + // Validate all migrations first (atomic: fail before any registration) + types := make([]reflect.Type, len(vm.Migrations)) + for i, tm := range vm.Migrations { + if tm.Type == nil { + return fmt.Errorf("migration %d: type cannot be nil", i) + } + if tm.Migration == nil { + return fmt.Errorf("migration %d: migration cannot be nil", i) + } + types[i] = reflect.TypeOf(tm.Type) + } + + // Check for duplicate types within the same registration + seen := make(map[reflect.Type]bool) + for i, t := range types { + if seen[t] { + return fmt.Errorf("migration %d: duplicate type %v", i, t) + } + seen[t] = true + } + + // All validations passed - register atomically + rm.mu.Lock() + defer rm.mu.Unlock() + + for i, tm := range vm.Migrations { + // Use internal registration (already holds lock) + if err := rm.registerTypeMigrationLocked(vm.Version, types[i], tm.Migration); err != nil { + // Rollback: remove any migrations we just added for this version + for j := 0; j < i; j++ { + rm.unregisterTypeMigrationLocked(vm.Version, types[j]) + } + return fmt.Errorf("migration %d (%v): %w", i, types[i], err) + } + } + + return nil +} +``` + +--- + +## Usage Examples + +### Before (Verbose) + +```go +rms.Register[User](rm, "2024-01-01", &UserMigration{}) +rms.Register[Address](rm, "2024-01-01", &AddressMigration{}) +rms.Register[Order](rm, "2024-01-01", &OrderMigration{}) +rms.Register[Payment](rm, "2024-01-01", &PaymentMigration{}) + +rms.Register[User](rm, "2024-06-01", &UserMigrationV2{}) +rms.Register[Workspace](rm, "2024-06-01", &WorkspaceMigration{}) +``` + +### After (Clean) + +```go +err := rms.RegisterVersion(rm, &rms.VersionMigrations{ + Version: "2024-01-01", + Migrations: []rms.TypedMigration{ + {Type: User{}, Migration: &UserMigration{}}, + {Type: Address{}, Migration: &AddressMigration{}}, + {Type: Order{}, Migration: &OrderMigration{}}, + {Type: Payment{}, Migration: &PaymentMigration{}}, + }, +}) +if err != nil { + log.Fatal(err) +} + +err = rms.RegisterVersion(rm, &rms.VersionMigrations{ + Version: "2024-06-01", + Migrations: []rms.TypedMigration{ + {Type: User{}, Migration: &UserMigrationV2{}}, + {Type: Workspace{}, Migration: &WorkspaceMigration{}}, + }, +}) +if err != nil { + log.Fatal(err) +} +``` + +--- + +## Error Handling + +The function validates all migrations before registering any: + +```go +// This will fail atomically - no partial registration +err := rms.RegisterVersion(rm, &rms.VersionMigrations{ + Version: "2024-01-01", + Migrations: []rms.TypedMigration{ + {Type: User{}, Migration: &UserMigration{}}, // Valid + {Type: nil, Migration: &AddressMigration{}}, // Invalid: nil type + {Type: Order{}, Migration: &OrderMigration{}}, // Never reached + }, +}) +// err: "migration 1: type cannot be nil" +// No migrations registered +``` + +--- + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| API style | Struct-based | Clear, self-documenting, extensible | +| Type specification | `Type any` with instance | Simpler than generics for bulk ops | +| Atomicity | All-or-nothing | Prevents inconsistent state | +| Validation order | Validate all → Register all | Fail fast, clean rollback | + +--- + +## Implementation Plan + +1. Add `TypedMigration` struct +2. Add `VersionMigrations` struct +3. Add internal `registerTypeMigrationLocked()` (assumes lock held) +4. Add internal `unregisterTypeMigrationLocked()` for rollback +5. Implement `RegisterVersion()` with validation and atomic registration +6. Add tests for successful bulk registration +7. Add tests for atomic failure (rollback on error) +8. Add tests for duplicate type detection +9. Update examples to use bulk registration + +--- + +## Open Questions + +1. Should `RegisterVersion` support registering the same type multiple times for different versions in a single call? (Current design: no, use separate calls) + +2. Should we add a `RegisterVersions` (plural) function that accepts multiple `VersionMigrations` for even more bulk registration? diff --git a/docs/context_propagation.md b/docs/context_propagation.md new file mode 100644 index 0000000..7af2762 --- /dev/null +++ b/docs/context_propagation.md @@ -0,0 +1,499 @@ +# Context Propagation Design + +**Status:** Implemented +**Author:** Subomi Oluwalana +**Date:** January 2026 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Problem](#problem) +3. [Solution: `rm.For(r)` Pattern](#solution-rmforr-pattern) + - [Type Definitions](#type-definitions) + - [API Surface](#api-surface) + - [Implementation](#implementation) + - [Usage Examples](#usage-examples) +4. [Implementation Plan](#implementation-plan) +5. [Open Questions](#open-questions) + +--- + +## Overview + +This document describes how to allow migrations to access request-scoped data (HTTP request, context, user version) without changing the external `Unmarshal` and `Marshal` API signatures. The internal `TypeMigration` interface may be modified if it provides a cleaner design. + +--- + +## Problem + +Migrations may need access to request-scoped data for: +- Logging with request context +- Tenant-specific migration logic +- Feature flags based on user/request headers +- Cancellation via `context.Context` + +The current `TypeMigration` interface mirrors `encoding/json`'s simplicity: + +```go +type TypeMigration interface { + MigrateForward(data any) (any, error) + MigrateBackward(data any) (any, error) +} +``` + +The current external API uses optional method chaining: + +```go +// Current API - request is optional (problematic) +rm.WithUserVersion(r).Unmarshal(data, &v) +rm.Marshal(v) // Works but skips migrations silently +``` + +We want to redesign the API so that: +1. Request context is **required** (not optional) +2. `Marshal`/`Unmarshal` signatures still mirror `encoding/json` +3. Context is propagated to migrations + +--- + +## Solution: `rm.For(r)` Pattern + +### Overview + +Introduce a `Migrator` type that holds request-scoped state. The `For(r)` method creates a `Migrator` for a specific request, and `Marshal`/`Unmarshal` live on `Migrator`. + +```go +// Usage +rm.For(r).Unmarshal(data, &v) +rm.For(r).Marshal(v) +``` + +This makes the request **required by design** - you can't call `Marshal`/`Unmarshal` without first calling `For(r)`. + +### Type Definitions + +```go +// RequestMigration holds configuration and registered migrations. +// It does NOT have Marshal/Unmarshal methods directly. +type RequestMigration struct { + opts *RequestMigrationOptions + versions []*Version + mu *sync.Mutex + migrations map[reflect.Type]map[string]TypeMigration + graphBuilder *TypeGraphBuilder + // ... other config fields +} + +// Migrator is a request-scoped handle for performing migrations. +// Created via RequestMigration.For(r). +type Migrator struct { + rm *RequestMigration + ctx context.Context + userVersion *Version +} + +// Context key for user version. +// Uses unexported type to prevent key collisions (standard Go pattern). +type userVersionKey struct{} + +// UserVersionFromContext retrieves the user's API version from a migration context. +// Returns nil if not present. +func UserVersionFromContext(ctx context.Context) *Version { + if v, ok := ctx.Value(userVersionKey{}).(*Version); ok { + return v + } + return nil +} + +// withUserVersion returns a new context with the user version attached. +// This is internal - callers don't need to use this directly. +func withUserVersion(ctx context.Context, version *Version) context.Context { + return context.WithValue(ctx, userVersionKey{}, version) +} +``` + +### API Surface + +```go +// RequestMigration methods (configuration) +func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, error) +func Register[T any](rm *RequestMigration, version string, m TypeMigration) error +func (rm *RequestMigration) RegisterMetrics(reg *prometheus.Registry) +func (rm *RequestMigration) WriteVersionHeader() func(next http.Handler) http.Handler + +// The key method - creates a request-scoped Migrator +func (rm *RequestMigration) For(r *http.Request) (*Migrator, error) + +// Bind is an alias for For +func (rm *RequestMigration) Bind(r *http.Request) (*Migrator, error) + +// Migrator methods (json-like API) +func (m *Migrator) Marshal(v interface{}) ([]byte, error) +func (m *Migrator) Unmarshal(data []byte, v interface{}) error + +// Context helper function (for use inside migrations) +func UserVersionFromContext(ctx context.Context) *Version +``` + +### Implementation + +#### For() Method + +```go +func (rm *RequestMigration) For(r *http.Request) (*Migrator, error) { + if r == nil { + return nil, errors.New("request cannot be nil") + } + + userVersion, err := rm.getUserVersion(r) + if err != nil { + return nil, err + } + + // Use request's context directly, only add user version + ctx := withUserVersion(r.Context(), userVersion) + + return &Migrator{ + rm: rm, + ctx: ctx, + userVersion: userVersion, + }, nil +} +``` + +#### Migrator.Unmarshal() + +```go +func (m *Migrator) Unmarshal(data []byte, v interface{}) error { + t := reflect.TypeOf(v) + if t.Kind() != reflect.Ptr { + return errors.New("v must be a pointer") + } + + graph, err := m.rm.graphBuilder.Build(t, m.userVersion) + if err != nil { + return err + } + + if !graph.HasMigrations() { + return json.Unmarshal(data, v) + } + + var intermediate any + if err := json.Unmarshal(data, &intermediate); err != nil { + return err + } + + // Pass context.Context to the graph for migrations to access + if err := graph.MigrateForward(m.ctx, &intermediate); err != nil { + return err + } + + data, err = json.Marshal(intermediate) + if err != nil { + return err + } + + return json.Unmarshal(data, v) +} +``` + +#### Migrator.Marshal() + +```go +func (m *Migrator) Marshal(v interface{}) ([]byte, error) { + graph, err := m.rm.graphBuilder.BuildFromValue(reflect.ValueOf(v), m.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 + } + + // Pass context.Context to the graph for migrations to access + if err := graph.MigrateBackward(m.ctx, &intermediate); err != nil { + return nil, err + } + + return json.Marshal(intermediate) +} +``` + +#### Updated TypeMigration Interface + +```go +// TypeMigration defines how to migrate a specific type. +// The context.Context parameter provides: +// - Cancellation/deadline from the original request (r.Context()) +// - User version via UserVersionFromContext(ctx) +// - Any custom values the caller adds via context.WithValue() +type TypeMigration interface { + MigrateForward(ctx context.Context, data any) (any, error) + MigrateBackward(ctx context.Context, data any) (any, error) +} +``` + +#### Updated TypeGraph Methods + +```go +func (g *TypeGraph) MigrateForward(ctx context.Context, data *any) error { + val := *data + if val == nil { + return nil + } + + // Handle nested fields first + switch v := val.(type) { + case map[string]interface{}: + for fieldName, fieldGraph := range g.Fields { + if fieldName == "__elem" { + continue + } + fieldData, ok := v[fieldName] + if !ok || fieldData == nil { + continue + } + if err := fieldGraph.MigrateForward(ctx, &fieldData); err != nil { + return err + } + v[fieldName] = fieldData + } + case []interface{}: + elemGraph := g.Fields["__elem"] + if elemGraph != nil { + for i := range v { + if err := elemGraph.MigrateForward(ctx, &v[i]); err != nil { + return err + } + } + } + } + + // Apply migrations with context + for _, m := range g.Migrations { + migratedData, err := m.MigrateForward(ctx, *data) + if err != nil { + return err + } + *data = migratedData + } + + return nil +} +``` + +### Usage Examples + +#### Handler Code + +```go +func CreateUserHandler(rm *RequestMigration) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Create request-scoped migrator + migrator, err := rm.For(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Unmarshal request body + var req CreateUserRequest + body, _ := io.ReadAll(r.Body) + if err := migrator.Unmarshal(body, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // ... business logic ... + + // Marshal response + resp := CreateUserResponse{ID: "123", Name: req.Name} + data, err := migrator.Marshal(resp) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) + } +} +``` + +#### Migration Code + +```go +type UserV2Migration struct{} + +func (m *UserV2Migration) MigrateForward(ctx context.Context, data any) (any, error) { + d := data.(map[string]any) + + // Access user version via helper function + userVersion := requestmigrations.UserVersionFromContext(ctx) + log.Printf("Migrating user for version %s", userVersion.String()) + + // Check for cancellation (standard context pattern) + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + // Transform: split "name" into "first_name" and "last_name" + if name, ok := d["name"].(string); ok { + parts := strings.SplitN(name, " ", 2) + d["first_name"] = parts[0] + if len(parts) > 1 { + d["last_name"] = parts[1] + } + delete(d, "name") + } + + return d, nil +} + +func (m *UserV2Migration) MigrateBackward(ctx context.Context, data any) (any, error) { + d := data.(map[string]any) + + // Transform: combine "first_name" and "last_name" into "name" + firstName, _ := d["first_name"].(string) + lastName, _ := d["last_name"].(string) + d["name"] = strings.TrimSpace(firstName + " " + lastName) + delete(d, "first_name") + delete(d, "last_name") + + return d, nil +} +``` + +#### Inter-Migration Communication + +Migrations can pass data to downstream migrations by adding values to the context. +Use private key types to avoid collisions (standard `context.WithValue` pattern). + +**Note:** Since `context.Context` is immutable, inter-migration communication requires +a different approach than a mutable map. Options include: + +1. **Store state in the data itself** (recommended for most cases) +2. **Use a shared mutable container passed via context** (for complex cases) + +```go +// Option 1: Store computed values in the data being migrated +type ComputedFieldMigration struct{} + +func (m *ComputedFieldMigration) MigrateForward(ctx context.Context, data any) (any, error) { + d := data.(map[string]any) + + // Store computed value directly in the data + d["__computed_hash"] = computeHash(d) + + return d, nil +} + +type DependentMigration struct{} + +func (m *DependentMigration) MigrateForward(ctx context.Context, data any) (any, error) { + d := data.(map[string]any) + + // Read value set by earlier migration + if hash, ok := d["__computed_hash"]; ok { + d["hash"] = hash + delete(d, "__computed_hash") // Clean up internal field + } + + return d, nil +} +``` + +```go +// Option 2: Use a shared container for complex inter-migration state +type migrationStateKey struct{} + +type MigrationState struct { + mu sync.Mutex + values map[string]any +} + +func (s *MigrationState) Set(key string, value any) { + s.mu.Lock() + defer s.mu.Unlock() + s.values[key] = value +} + +func (s *MigrationState) Get(key string) any { + s.mu.Lock() + defer s.mu.Unlock() + return s.values[key] +} + +// In Migrator.For(): +state := &MigrationState{values: make(map[string]any)} +ctx = context.WithValue(ctx, migrationStateKey{}, state) + +// In migrations: +func (m *MyMigration) MigrateForward(ctx context.Context, data any) (any, error) { + state := ctx.Value(migrationStateKey{}).(*MigrationState) + state.Set("computed_hash", computeHash(data)) + return data, nil +} +``` + +--- + +## Implementation Plan + +### Phase 1: Core Types + +1. Add context key type (`userVersionKey`) +2. Add helper functions: `UserVersionFromContext()`, `withUserVersion()` (internal) +3. Add `Migrator` struct with `rm *RequestMigration`, `ctx context.Context`, `userVersion *Version` +4. Update `TypeMigration` interface to accept `context.Context` parameter + +### Phase 2: API Changes + +5. Add `For(r *http.Request) (*Migrator, error)` method to `RequestMigration` +6. Move `Marshal()` from `RequestMigration` to `Migrator` +7. Move `Unmarshal()` from `RequestMigration` to `Migrator` +8. Remove `WithUserVersion()` method (superseded by `For()`) +9. Remove `request` field from `RequestMigration` struct + +### Phase 3: Internal Updates + +10. Update `TypeGraph.MigrateForward()` to accept `context.Context` +11. Update `TypeGraph.MigrateBackward()` to accept `context.Context` +12. Update all internal migration invocations to pass context + +### Phase 4: Examples and Tests + +13. Update all example migrations to new `TypeMigration` signature +14. Update all tests to use `rm.For(r).Marshal/Unmarshal` pattern +15. Add tests for `UserVersionFromContext()` in migrations +16. Add tests verifying `For(nil)` returns error +17. Add tests for context cancellation propagation + +### Phase 5: Documentation + +18. Update README with new API +19. Add migration guide for upgrading from `WithUserVersion` to `For` +20. Document `UserVersionFromContext()` and custom context value patterns + +--- + +## Open Questions + +1. **Inter-migration state**: Should we provide a built-in `MigrationState` container for complex inter-migration communication, or leave this to users? + +2. **Reusability**: Should `Migrator` be safe to reuse across multiple `Marshal`/`Unmarshal` calls for the same request? (Current design: yes, context is immutable so this is safe) diff --git a/docs/runtime_type_detection.md b/docs/runtime_type_detection.md new file mode 100644 index 0000000..135ee14 --- /dev/null +++ b/docs/runtime_type_detection.md @@ -0,0 +1,366 @@ +# Runtime Type Detection for Dynamic Fields + +**Status:** Implemented +**Author:** Subomi Oluwalana +**Date:** January 2026 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Problem](#problem) +3. [Solution](#solution) + - [Why Not Modify migrateForward/migrateBackward?](#why-not-modify-migrateforwardmigratebackward) + - [Actual Implementation: Capture Types Before JSON Serialization](#actual-implementation-capture-types-before-json-serialization) +4. [Implementation](#implementation) + - [TypeGraphBuilder.BuildFromValue](#typegraphbuilderbuildfromvalue) + - [TypeGraphBuilder.buildFromValueRecursive](#typegraphbuilderbuildfromvaluerecursive) + - [Migrator.Marshal Integration](#migratormarshal-integration) +5. [Key Insight](#key-insight) +6. [Usage Examples](#usage-examples) +7. [Test Cases](#test-cases) +8. [Considerations](#considerations) + - [Performance](#performance) + - [Type Resolution](#type-resolution) + - [Backward Compatibility](#backward-compatibility) +9. [References](#references) + +--- + +## Overview + +This document describes how runtime type detection enables migrations to work with `interface{}` fields, where the concrete type is only known at runtime rather than compile time. + +--- + +## Problem + +The original `TypeGraphBuilder.Build` function uses static type reflection (`field.Type`) to build the migration type graph. This works for statically-typed struct fields but fails for: + +1. **`interface{}` fields** - The compile-time type is `interface{}`, so nested migrations aren't discovered + +### Example Case + +```go +type PagedResponse struct { + Content interface{} `json:"content"` + Pagination *Pagination `json:"pagination"` +} + +// User registers migration for EndpointResponse +Register[EndpointResponse](rm, "2024-01-01", &EndpointMigration{}) + +// Previously, this would NOT apply EndpointResponse migrations to Content: +migrator.Marshal(&PagedResponse{Content: []EndpointResponse{...}}) +``` + +--- + +## Solution + +### Why Not Modify migrateForward/migrateBackward? + +The initial design considered detecting runtime types during migration by iterating over data fields. However, this approach has a fundamental flaw: + +```go +// This DOESN'T work: +case map[string]interface{}: + for fieldName, fieldData := range v { + runtimeType := reflect.TypeOf(fieldData) // Returns map[string]interface{}, NOT EndpointResponse! + } +``` + +After JSON round-tripping (`json.Marshal` → `json.Unmarshal`), the original Go type information is lost: + +``` +Original: EndpointResponse{Name: "test"} + ↓ json.Marshal +JSON: {"name": "test"} + ↓ json.Unmarshal into interface{} +Result: map[string]interface{}{"name": "test"} ← Type information LOST +``` + +### Actual Implementation: Capture Types Before JSON Serialization + +The solution is to inspect runtime types **before** JSON serialization, when we still have access to the original Go values. + +--- + +## Implementation + +### TypeGraphBuilder.BuildFromValue + +The `TypeGraphBuilder` struct handles graph construction. The `BuildFromValue` method is the entry point for value-based type graph building: + +```go +// BuildFromValue builds a type graph from a value, enabling runtime type detection for interface{} fields. +func (b *TypeGraphBuilder) BuildFromValue(v reflect.Value, userVersion *Version) (*TypeGraph, error) { + return b.buildFromValueRecursive(v, userVersion, make(map[uintptr]bool)) +} +``` + +### TypeGraphBuilder.buildFromValueRecursive + +The recursive implementation traverses the value tree: + +```go +func (b *TypeGraphBuilder) buildFromValueRecursive(v reflect.Value, userVersion *Version, visited map[uintptr]bool) (*TypeGraph, error) { + // Dereference pointers + for v.Kind() == reflect.Ptr { + if v.IsNil() { + return &TypeGraph{Fields: make(map[string]*TypeGraph)}, nil + } + v = v.Elem() + } + + // Cycle detection using pointer addresses + if v.Kind() == reflect.Struct && v.CanAddr() { + addr := v.UnsafeAddr() + if visited[addr] { + return &TypeGraph{Fields: make(map[string]*TypeGraph)}, nil + } + visited[addr] = true + } + + t := v.Type() + graph := &TypeGraph{ + Type: t, + Fields: make(map[string]*TypeGraph), + } + graph.Migrations = b.finder.FindMigrationsForType(t, userVersion) + + // Handle slices - use first element to determine type + if v.Kind() == reflect.Slice || v.Kind() == reflect.Array { + if v.Len() > 0 { + elemValue := v.Index(0) + // If element is interface{}, get the concrete value + if elemValue.Kind() == reflect.Interface && !elemValue.IsNil() { + elemValue = elemValue.Elem() + } + elemGraph, err := b.buildFromValueRecursive(elemValue, userVersion, visited) + if err != nil { + return nil, err + } + if elemGraph.HasMigrations() { + graph.Fields["__elem"] = elemGraph + } + } + } + + // Handle structs + if v.Kind() == reflect.Struct { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + fieldValue := v.Field(i) + + if !fieldValue.CanInterface() { + continue + } + + var fieldGraph *TypeGraph + var err error + + // KEY: For interface{} fields, use the runtime value's type + if field.Type.Kind() == reflect.Interface { + if fieldValue.IsNil() { + continue + } + // Get the concrete value from the interface + actualValue := fieldValue.Elem() // ← This gives us the REAL type! + fieldGraph, err = b.buildFromValueRecursive(actualValue, userVersion, visited) + } else { + fieldGraph, err = b.buildFromValueRecursive(fieldValue, userVersion, visited) + } + + if err != nil { + 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 + } + } + } + + return graph, nil +} +``` + +### Migrator.Marshal Integration + +The `Migrator.Marshal` method uses `BuildFromValue` to detect runtime types: + +```go +func (m *Migrator) Marshal(v interface{}) ([]byte, error) { + startTime := time.Now() + + // Use value-based graph building to detect interface{} fields at runtime + graph, err := m.rm.graphBuilder.BuildFromValue(reflect.ValueOf(v), m.userVersion) + if err != nil { + return nil, err + } + + if !graph.HasMigrations() { + return json.Marshal(v) + } + + currentVersion := m.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 := graph.MigrateBackward(m.ctx, &intermediate); err != nil { + return nil, err + } + + result, err := json.Marshal(intermediate) + if err != nil { + return nil, err + } + + m.rm.observeRequestLatency(currentVersion, m.userVersion, startTime) + + return result, nil +} +``` + +--- + +## Key Insight + +The critical insight is **when** to capture runtime type information: + +| Approach | Timing | Works? | +|----------|--------|--------| +| Modify migrateForward/migrateBackward | After JSON round-trip | No - Type info lost | +| BuildFromValue | Before JSON serialization | Yes - Type info intact | + +By using `reflect.Value.Elem()` on interface fields **before** JSON marshaling, we get the actual concrete type (`EndpointResponse`) rather than the generic `map[string]interface{}`. + +--- + +## Usage Examples + +### Handler Code + +```go +func ListEndpointsHandler(rm *RequestMigration) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + migrator, err := rm.For(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Get endpoints from database + endpoints := db.GetEndpoints() + + // Wrap in paged response with interface{} field + response := &PagedResponse{ + Content: endpoints, // []EndpointResponse stored in interface{} + Page: 1, + TotalPages: 5, + } + + // Migrations are automatically applied to Content field + data, err := migrator.Marshal(response) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) + } +} +``` + +### Type Definitions + +```go +type PagedResponse struct { + Content interface{} `json:"content"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` +} + +type EndpointResponse struct { + Name string `json:"name"` + Description string `json:"description"` +} +``` + +### Migration Registration + +```go +rm, _ := NewRequestMigration(&RequestMigrationOptions{ + VersionHeader: "X-API-Version", + CurrentVersion: "2024-06-01", + VersionFormat: DateFormat, +}) + +// Register migration for EndpointResponse +Register[EndpointResponse](rm, "2024-01-01", &EndpointMigration{}) +``` + +--- + +## Test Cases + +All test cases passing: + +```go +func Test_InterfaceFieldMigration(t *testing.T) { + // Marshal single item in interface field + // Marshal slice in interface field + // Marshal with current version - no migration + // Marshal nil interface field + // Marshal unregistered type in interface field +} + +func Test_NestedInterfaceSliceMigration(t *testing.T) { + // Marshal pointer slice in interface field +} +``` + +--- + +## Considerations + +### Performance + +- Runtime type detection adds overhead for value traversal +- Uses pointer-address-based cycle detection (`map[uintptr]bool`) +- No caching for value-based graphs (each `Marshal` call builds fresh) +- This is acceptable because `Marshal` is called per-request and values change + +### Type Resolution + +- Since we capture Go types via `reflect.Value.Elem()` before JSON serialization, type identification is unambiguous +- No structural matching needed - we have the exact runtime type + +### Backward Compatibility + +- Existing behavior for statically-typed fields remains unchanged +- New behavior only activates for `interface{}` fields with runtime values +- `Unmarshal` still uses type-based graph (`Build`) since we don't have the value yet + +--- + +## References + +- **Implementation:** `TypeGraphBuilder.BuildFromValue` and `TypeGraphBuilder.buildFromValueRecursive` in `requestmigrations.go` +- **Tests:** `Test_InterfaceFieldMigration` and `Test_NestedInterfaceSliceMigration` in `requestmigrations_test.go` +- **Related:** [Context Propagation Design](context_propagation.md) for the `Migrator` pattern diff --git a/examples/advanced/main.go b/examples/advanced/main.go index 88d055b..21a09d8 100644 --- a/examples/advanced/main.go +++ b/examples/advanced/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "log" @@ -35,7 +36,7 @@ type Project struct { // UserMigration handles email -> username renaming type UserMigrationV20230601 struct{} -func (m *UserMigrationV20230601) MigrateForward(data any) (any, error) { +func (m *UserMigrationV20230601) MigrateForward(ctx context.Context, data any) (any, error) { d := data.(map[string]any) if email, ok := d["email"].(string); ok { d["username"] = email @@ -44,7 +45,7 @@ func (m *UserMigrationV20230601) MigrateForward(data any) (any, error) { return d, nil } -func (m *UserMigrationV20230601) MigrateBackward(data any) (any, error) { +func (m *UserMigrationV20230601) MigrateBackward(ctx context.Context, data any) (any, error) { d := data.(map[string]any) if username, ok := d["username"].(string); ok { d["email"] = username @@ -56,7 +57,7 @@ func (m *UserMigrationV20230601) MigrateBackward(data any) (any, error) { // WorkspaceMigration handles projects slice -> map conversion type WorkspaceMigrationV20240101 struct{} -func (m *WorkspaceMigrationV20240101) MigrateForward(data any) (any, error) { +func (m *WorkspaceMigrationV20240101) MigrateForward(ctx context.Context, data any) (any, error) { d := data.(map[string]any) if projects, ok := d["projects"].([]any); ok { projectMap := make(map[string]any) @@ -71,7 +72,7 @@ func (m *WorkspaceMigrationV20240101) MigrateForward(data any) (any, error) { return d, nil } -func (m *WorkspaceMigrationV20240101) MigrateBackward(data any) (any, error) { +func (m *WorkspaceMigrationV20240101) MigrateBackward(ctx context.Context, data any) (any, error) { d := data.(map[string]any) if projectMap, ok := d["projects"].(map[string]any); ok { projects := make([]any, 0, len(projectMap)) @@ -117,7 +118,12 @@ func main() { req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-API-Version", "2023-01-01") - data, err := rm.WithUserVersion(req).Marshal(w) + migrator, err := rm.For(req) + if err != nil { + log.Fatal(err) + } + + data, err := migrator.Marshal(w) if err != nil { log.Fatal(err) } @@ -137,7 +143,7 @@ func main() { }` var incoming Workspace - err = rm.WithUserVersion(req).Unmarshal([]byte(oldJSON), &incoming) + err = migrator.Unmarshal([]byte(oldJSON), &incoming) if err != nil { log.Fatal(err) } diff --git a/examples/basic/main.go b/examples/basic/main.go index f88fcf0..8d4565c 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -81,8 +81,13 @@ func (a *API) ListUser(w http.ResponseWriter, r *http.Request) { return } - // Use the new API to marshal and migrate the users - data, err := a.rm.WithUserVersion(r).Marshal(users) + migrator, err := a.rm.For(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + data, err := migrator.Marshal(users) if err != nil { w.WriteHeader(http.StatusInternalServerError) return @@ -106,8 +111,13 @@ func (a *API) GetUser(w http.ResponseWriter, r *http.Request) { return } - // Use the new API to marshal and migrate the user - data, err := a.rm.WithUserVersion(r).Marshal(user) + migrator, err := a.rm.For(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + data, err := migrator.Marshal(user) if err != nil { w.WriteHeader(http.StatusInternalServerError) return diff --git a/examples/basic/requestmigrations.go b/examples/basic/requestmigrations.go index 16684ab..313517e 100644 --- a/examples/basic/requestmigrations.go +++ b/examples/basic/requestmigrations.go @@ -1,13 +1,14 @@ package main import ( + "context" "strings" ) // UserMigration handles migrations for the User type type UserMigration struct{} -func (m *UserMigration) MigrateForward(data any) (any, error) { +func (m *UserMigration) MigrateForward(ctx context.Context, data any) (any, error) { d, ok := data.(map[string]any) if !ok { return data, nil @@ -28,7 +29,7 @@ func (m *UserMigration) MigrateForward(data any) (any, error) { return d, nil } -func (m *UserMigration) MigrateBackward(data any) (any, error) { +func (m *UserMigration) MigrateBackward(ctx context.Context, data any) (any, error) { d, ok := data.(map[string]any) if !ok { return data, nil @@ -45,7 +46,7 @@ func (m *UserMigration) MigrateBackward(data any) (any, error) { // ProfileMigration handles migrations for the profile type if needed type ProfileMigration struct{} -func (m *ProfileMigration) MigrateForward(data any) (any, error) { +func (m *ProfileMigration) MigrateForward(ctx context.Context, 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 { @@ -56,7 +57,7 @@ func (m *ProfileMigration) MigrateForward(data any) (any, error) { return data, nil } -func (m *ProfileMigration) MigrateBackward(data any) (any, error) { +func (m *ProfileMigration) MigrateBackward(ctx context.Context, data any) (any, error) { d, ok := data.(map[string]any) if !ok { return data, nil diff --git a/requestmigrations.go b/requestmigrations.go index 000719f..c741e54 100644 --- a/requestmigrations.go +++ b/requestmigrations.go @@ -1,6 +1,7 @@ package requestmigrations import ( + "context" "encoding/json" "errors" "net/http" @@ -13,6 +14,20 @@ import ( "github.com/prometheus/client_golang/prometheus" ) +type userVersionKey struct{} + +// UserVersionFromContext retrieves the user's API version from a migration context. +func UserVersionFromContext(ctx context.Context) *Version { + if v, ok := ctx.Value(userVersionKey{}).(*Version); ok { + return v + } + return nil +} + +func withUserVersion(ctx context.Context, version *Version) context.Context { + return context.WithValue(ctx, userVersionKey{}, version) +} + type VersionFormat string const ( @@ -60,10 +75,14 @@ type RequestMigration struct { mu *sync.Mutex migrations map[reflect.Type]map[string]TypeMigration // type -> version -> migration - cacheMu *sync.RWMutex - cache map[graphCacheKey]*TypeGraph + graphBuilder *TypeGraphBuilder +} - request *http.Request // context for a specific request +// Migrator is a request-scoped handle for performing migrations. +type Migrator struct { + rm *RequestMigration + ctx context.Context + userVersion *Version } type graphCacheKey struct { @@ -96,16 +115,18 @@ func NewRequestMigration(opts *RequestMigrationOptions) (*RequestMigration, erro versions := make([]*Version, 0, 1) versions = append(versions, &Version{Format: opts.VersionFormat, Value: iv}) - return &RequestMigration{ + rm := &RequestMigration{ 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 + } + + rm.graphBuilder = NewTypeGraphBuilder(rm) + + return rm, nil } func (rm *RequestMigration) getUserVersion(req *http.Request) (*Version, error) { @@ -178,30 +199,36 @@ func (rm *RequestMigration) RegisterMetrics(reg *prometheus.Registry) { reg.MustRegister(rm.metric) } -func (rm *RequestMigration) WithUserVersion(r *http.Request) *RequestMigration { - newRM := *rm - newRM.request = r - return &newRM -} +// For creates a request-scoped Migrator for performing migrations. +func (rm *RequestMigration) For(r *http.Request) (*Migrator, error) { + if r == nil { + return nil, errors.New("request cannot be nil") + } -func (rm *RequestMigration) Marshal(v interface{}) ([]byte, error) { - startTime := time.Now() + userVersion, err := rm.getUserVersion(r) + if err != nil { + return nil, err + } - var ( - err error - userVersion *Version - ) + // Use request's context directly, only add user version + ctx := withUserVersion(r.Context(), userVersion) - if rm.request != nil { - userVersion, err = rm.getUserVersion(rm.request) - if err != nil { - return nil, err - } - } else { - userVersion = rm.getCurrentVersion() - } + return &Migrator{ + rm: rm, + ctx: ctx, + userVersion: userVersion, + }, nil +} + +// Bind is an alias for For. +func (rm *RequestMigration) Bind(r *http.Request) (*Migrator, error) { + return rm.For(r) +} - graph, err := rm.buildTypeGraph(reflect.TypeOf(v), userVersion) +func (m *Migrator) Marshal(v interface{}) ([]byte, error) { + startTime := time.Now() + + graph, err := m.rm.graphBuilder.BuildFromValue(reflect.ValueOf(v), m.userVersion) if err != nil { return nil, err } @@ -210,7 +237,7 @@ func (rm *RequestMigration) Marshal(v interface{}) ([]byte, error) { return json.Marshal(v) } - currentVersion := rm.getCurrentVersion() + currentVersion := m.rm.getCurrentVersion() data, err := json.Marshal(v) if err != nil { @@ -222,7 +249,7 @@ func (rm *RequestMigration) Marshal(v interface{}) ([]byte, error) { return nil, err } - if err := rm.migrateBackward(graph, &intermediate); err != nil { + if err := graph.MigrateBackward(m.ctx, &intermediate); err != nil { return nil, err } @@ -232,34 +259,20 @@ func (rm *RequestMigration) Marshal(v interface{}) ([]byte, error) { } // Observe migration latency: from current version -> to user version (backward migration) - rm.observeRequestLatency(currentVersion, userVersion, startTime) + m.rm.observeRequestLatency(currentVersion, m.userVersion, startTime) return result, nil } -func (rm *RequestMigration) Unmarshal(data []byte, v interface{}) error { +func (m *Migrator) 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") } - graph, err := rm.buildTypeGraph(t, userVersion) + graph, err := m.rm.graphBuilder.Build(t, m.userVersion) if err != nil { return err } @@ -268,14 +281,14 @@ func (rm *RequestMigration) Unmarshal(data []byte, v interface{}) error { return json.Unmarshal(data, v) } - currentVersion := rm.getCurrentVersion() + currentVersion := m.rm.getCurrentVersion() var intermediate any if err := json.Unmarshal(data, &intermediate); err != nil { return err } - if err := rm.migrateForward(graph, &intermediate); err != nil { + if err := graph.MigrateForward(m.ctx, &intermediate); err != nil { return err } @@ -289,7 +302,7 @@ func (rm *RequestMigration) Unmarshal(data []byte, v interface{}) error { } // Observe migration latency: from user version -> to current version (forward migration) - rm.observeRequestLatency(userVersion, currentVersion, startTime) + m.rm.observeRequestLatency(m.userVersion, currentVersion, startTime) return nil } @@ -297,10 +310,10 @@ func (rm *RequestMigration) Unmarshal(data []byte, v interface{}) error { // TypeMigration defines how to migrate a specific type type TypeMigration interface { // MigrateForward transforms data from old version to new - MigrateForward(data any) (any, error) + MigrateForward(ctx context.Context, data any) (any, error) // MigrateBackward transforms data from new version to old - MigrateBackward(data any) (any, error) + MigrateBackward(ctx context.Context, data any) (any, error) } // MigrationVersion represents all type migrations for a specific version @@ -316,10 +329,29 @@ type TypeGraph struct { Migrations []TypeMigration } -func (rm *RequestMigration) buildTypeGraph(t reflect.Type, userVersion *Version) (*TypeGraph, error) { - if t.Kind() == reflect.Ptr { - t = t.Elem() +// MigrationFinder abstracts the lookup of migrations for a given type and version +type MigrationFinder interface { + FindMigrationsForType(t reflect.Type, version *Version) []TypeMigration +} + +// TypeGraphBuilder handles the construction of TypeGraph instances +type TypeGraphBuilder struct { + finder MigrationFinder + cacheMu *sync.RWMutex + cache map[graphCacheKey]*TypeGraph +} + +// NewTypeGraphBuilder creates a new TypeGraphBuilder with the given migration finder +func NewTypeGraphBuilder(finder MigrationFinder) *TypeGraphBuilder { + return &TypeGraphBuilder{ + finder: finder, + cacheMu: new(sync.RWMutex), + cache: make(map[graphCacheKey]*TypeGraph), } +} + +func (b *TypeGraphBuilder) Build(t reflect.Type, userVersion *Version) (*TypeGraph, error) { + t = dereferenceToLastPtr(t) key := graphCacheKey{ t: t, @@ -327,33 +359,33 @@ func (rm *RequestMigration) buildTypeGraph(t reflect.Type, userVersion *Version) } // 1. Check cache (O(1)) - rm.cacheMu.RLock() - if g, ok := rm.cache[key]; ok { - rm.cacheMu.RUnlock() + b.cacheMu.RLock() + if g, ok := b.cache[key]; ok { + b.cacheMu.RUnlock() return g, nil } - rm.cacheMu.RUnlock() + b.cacheMu.RUnlock() // 2. Build graph (DFS with internal cycle detection) - rm.cacheMu.Lock() - defer rm.cacheMu.Unlock() + b.cacheMu.Lock() + defer b.cacheMu.Unlock() // Re-check cache after acquiring write lock - if g, ok := rm.cache[key]; ok { + if g, ok := b.cache[key]; ok { return g, nil } - g, err := rm.buildTypeGraphRecursive(t, userVersion, make(map[reflect.Type]*TypeGraph)) + g, err := b.buildRecursive(t, userVersion, make(map[reflect.Type]*TypeGraph)) if err != nil { return nil, err } // 3. Cache the result - rm.cache[key] = g + b.cache[key] = g return g, nil } -func (rm *RequestMigration) buildTypeGraphRecursive(t reflect.Type, userVersion *Version, visited map[reflect.Type]*TypeGraph) (*TypeGraph, error) { +func (b *TypeGraphBuilder) buildRecursive(t reflect.Type, userVersion *Version, visited map[reflect.Type]*TypeGraph) (*TypeGraph, error) { if t.Kind() == reflect.Ptr { t = t.Elem() } @@ -368,10 +400,10 @@ func (rm *RequestMigration) buildTypeGraphRecursive(t reflect.Type, userVersion } visited[t] = graph - graph.Migrations = rm.findMigrationsForType(t, userVersion) + graph.Migrations = b.finder.FindMigrationsForType(t, userVersion) if t.Kind() == reflect.Slice || t.Kind() == reflect.Array { - elemGraph, err := rm.buildTypeGraphRecursive(t.Elem(), userVersion, visited) + elemGraph, err := b.buildRecursive(t.Elem(), userVersion, visited) if err != nil { return nil, err } @@ -384,7 +416,93 @@ func (rm *RequestMigration) buildTypeGraphRecursive(t reflect.Type, userVersion if t.Kind() == reflect.Struct { for i := 0; i < t.NumField(); i++ { field := t.Field(i) - fieldGraph, err := rm.buildTypeGraphRecursive(field.Type, userVersion, visited) + fieldGraph, err := b.buildRecursive(field.Type, userVersion, visited) + if err != nil { + 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 + } + } + } + + return graph, nil +} + +// BuildFromValue builds a type graph from a value, enabling runtime type detection for interface{} fields. +func (b *TypeGraphBuilder) BuildFromValue(v reflect.Value, userVersion *Version) (*TypeGraph, error) { + return b.buildFromValueRecursive(v, userVersion, make(map[uintptr]bool)) +} + +func (b *TypeGraphBuilder) buildFromValueRecursive(v reflect.Value, userVersion *Version, visited map[uintptr]bool) (*TypeGraph, error) { + for v.Kind() == reflect.Ptr { + if v.IsNil() { + return &TypeGraph{Fields: make(map[string]*TypeGraph)}, nil + } + v = v.Elem() + } + + if v.Kind() == reflect.Struct && v.CanAddr() { + addr := v.UnsafeAddr() + if visited[addr] { + return &TypeGraph{Fields: make(map[string]*TypeGraph)}, nil + } + visited[addr] = true + } + + t := v.Type() + + graph := &TypeGraph{ + Type: t, + Fields: make(map[string]*TypeGraph), + } + + graph.Migrations = b.finder.FindMigrationsForType(t, userVersion) + + if v.Kind() == reflect.Slice || v.Kind() == reflect.Array { + if v.Len() > 0 { + elemValue := v.Index(0) + if elemValue.Kind() == reflect.Interface && !elemValue.IsNil() { + elemValue = elemValue.Elem() + } + + elemGraph, err := b.buildFromValueRecursive(elemValue, userVersion, visited) + if err != nil { + return nil, err + } + if elemGraph.HasMigrations() { + graph.Fields["__elem"] = elemGraph + } + } + } + + if v.Kind() == reflect.Struct { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + fieldValue := v.Field(i) + + if !fieldValue.CanInterface() { + continue + } + + var fieldGraph *TypeGraph + var err error + + if field.Type.Kind() == reflect.Interface { + if fieldValue.IsNil() { + continue + } + actualValue := fieldValue.Elem() + fieldGraph, err = b.buildFromValueRecursive(actualValue, userVersion, visited) + } else { + fieldGraph, err = b.buildFromValueRecursive(fieldValue, userVersion, visited) + } + if err != nil { return nil, err } @@ -416,7 +534,8 @@ func (g *TypeGraph) HasMigrations() bool { return false } -func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any) error { +// MigrateForward applies forward migrations (old version -> new version) to the data +func (g *TypeGraph) MigrateForward(ctx context.Context, data *any) error { val := *data if val == nil { return nil @@ -424,7 +543,7 @@ func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any) error { switch v := val.(type) { case map[string]interface{}: - for fieldName, fieldGraph := range graph.Fields { + for fieldName, fieldGraph := range g.Fields { if fieldName == "__elem" { continue } @@ -432,24 +551,24 @@ func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any) error { if !ok || fieldData == nil { continue } - if err := rm.migrateForward(fieldGraph, &fieldData); err != nil { + if err := fieldGraph.MigrateForward(ctx, &fieldData); err != nil { return err } v[fieldName] = fieldData } case []interface{}: - elemGraph := graph.Fields["__elem"] + elemGraph := g.Fields["__elem"] if elemGraph != nil { for i := range v { - if err := rm.migrateForward(elemGraph, &v[i]); err != nil { + if err := elemGraph.MigrateForward(ctx, &v[i]); err != nil { return err } } } } - for _, m := range graph.Migrations { - migratedData, err := m.MigrateForward(*data) + for _, m := range g.Migrations { + migratedData, err := m.MigrateForward(ctx, *data) if err != nil { return err } @@ -459,14 +578,15 @@ func (rm *RequestMigration) migrateForward(graph *TypeGraph, data *any) error { return nil } -func (rm *RequestMigration) migrateBackward(graph *TypeGraph, data *any) error { +// MigrateBackward applies backward migrations (new version -> old version) to the data +func (g *TypeGraph) MigrateBackward(ctx context.Context, 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) + for i := len(g.Migrations) - 1; i >= 0; i-- { + m := g.Migrations[i] + migratedData, err := m.MigrateBackward(ctx, *data) if err != nil { return err } @@ -477,7 +597,7 @@ func (rm *RequestMigration) migrateBackward(graph *TypeGraph, data *any) error { switch v := val.(type) { case map[string]interface{}: - for fieldName, fieldGraph := range graph.Fields { + for fieldName, fieldGraph := range g.Fields { if fieldName == "__elem" { continue } @@ -485,16 +605,16 @@ func (rm *RequestMigration) migrateBackward(graph *TypeGraph, data *any) error { if !ok || fieldData == nil { continue } - if err := rm.migrateBackward(fieldGraph, &fieldData); err != nil { + if err := fieldGraph.MigrateBackward(ctx, &fieldData); err != nil { return err } v[fieldName] = fieldData } case []interface{}: - elemGraph := graph.Fields["__elem"] + elemGraph := g.Fields["__elem"] if elemGraph != nil { for i := range v { - if err := rm.migrateBackward(elemGraph, &v[i]); err != nil { + if err := elemGraph.MigrateBackward(ctx, &v[i]); err != nil { return err } } @@ -548,7 +668,7 @@ func (rm *RequestMigration) registerTypeMigration(version string, t reflect.Type return nil } -func (rm *RequestMigration) findMigrationsForType(t reflect.Type, userVersion *Version) []TypeMigration { +func (rm *RequestMigration) FindMigrationsForType(t reflect.Type, userVersion *Version) []TypeMigration { rm.mu.Lock() defer rm.mu.Unlock() diff --git a/requestmigrations_test.go b/requestmigrations_test.go index 3f911cd..baee043 100644 --- a/requestmigrations_test.go +++ b/requestmigrations_test.go @@ -1,6 +1,7 @@ package requestmigrations import ( + "context" "encoding/json" "fmt" "net/http" @@ -34,11 +35,11 @@ type address struct { type addressMigration struct{} -func (m *addressMigration) MigrateForward(data any) (any, error) { +func (m *addressMigration) MigrateForward(ctx context.Context, data any) (any, error) { return nil, nil } -func (m *addressMigration) MigrateBackward(data any) (any, error) { +func (m *addressMigration) MigrateBackward(ctx context.Context, data any) (any, error) { a, ok := data.(map[string]interface{}) if !ok { return nil, fmt.Errorf("bad type") @@ -97,7 +98,10 @@ func Test_Marshal(t *testing.T) { Postcode: "CR0 1GB", }, } - bytesW, err := rm.WithUserVersion(req).Marshal(&pStruct) + migrator, err := rm.For(req) + require.NoError(t, err) + + bytesW, err := migrator.Marshal(&pStruct) _ = bytesW tc.assert(t, err) @@ -111,7 +115,7 @@ type AddressString string type addressStringMigration struct{} -func (m *addressStringMigration) MigrateForward(data any) (any, error) { +func (m *addressStringMigration) MigrateForward(ctx context.Context, data any) (any, error) { s, ok := data.(string) if !ok { return nil, fmt.Errorf("bad type") @@ -119,7 +123,7 @@ func (m *addressStringMigration) MigrateForward(data any) (any, error) { return AddressString("Migrated: " + s), nil } -func (m *addressStringMigration) MigrateBackward(data any) (any, error) { +func (m *addressStringMigration) MigrateBackward(ctx context.Context, data any) (any, error) { s, ok := data.(string) if !ok { return nil, fmt.Errorf("bad type") @@ -145,7 +149,10 @@ func Test_CustomPrimitive(t *testing.T) { req.Header.Add("X-Test-Version", "2023-02-01") u := CustomUser{Address: "Main St"} - data, err := rm.WithUserVersion(req).Marshal(&u) + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(&u) require.NoError(t, err) var res map[string]interface{} @@ -159,7 +166,10 @@ func Test_CustomPrimitive(t *testing.T) { jsonData := `{"address": "Main St"}` var u CustomUser - err := rm.WithUserVersion(req).Unmarshal([]byte(jsonData), &u) + migrator, err := rm.For(req) + require.NoError(t, err) + + err = migrator.Unmarshal([]byte(jsonData), &u) require.NoError(t, err) require.Equal(t, AddressString("Migrated: Main St"), u.Address) }) @@ -184,7 +194,7 @@ func Test_Cycles(t *testing.T) { 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"}) + _, err := rm.graphBuilder.Build(reflect.TypeOf(CyclicUser{}), &Version{Format: DateFormat, Value: "2023-01-01"}) require.NoError(t, err) }) } @@ -206,7 +216,10 @@ func Test_RootSlice(t *testing.T) { {Address: "Main St"}, {Address: "Second St"}, } - data, err := rm.WithUserVersion(req).Marshal(&users) + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(&users) require.NoError(t, err) var res []map[string]interface{} @@ -222,7 +235,10 @@ func Test_RootSlice(t *testing.T) { jsonData := `[{"address": "Main St"}, {"address": "Second St"}]` var users []CustomUser - err := rm.WithUserVersion(req).Unmarshal([]byte(jsonData), &users) + migrator, err := rm.For(req) + require.NoError(t, err) + + err = migrator.Unmarshal([]byte(jsonData), &users) require.NoError(t, err) require.Len(t, users, 2) require.Equal(t, AddressString("Migrated: Main St"), users[0].Address) @@ -232,22 +248,22 @@ func Test_RootSlice(t *testing.T) { type chainMigrationV2 struct{} -func (m *chainMigrationV2) MigrateForward(data any) (any, error) { +func (m *chainMigrationV2) MigrateForward(ctx context.Context, data any) (any, error) { s := data.(string) return s + " -> v2", nil } -func (m *chainMigrationV2) MigrateBackward(data any) (any, error) { +func (m *chainMigrationV2) MigrateBackward(ctx context.Context, data any) (any, error) { s := data.(string) return s + " -> v1", nil } type chainMigrationV3 struct{} -func (m *chainMigrationV3) MigrateForward(data any) (any, error) { +func (m *chainMigrationV3) MigrateForward(ctx context.Context, data any) (any, error) { s := data.(string) return s + " -> v3", nil } -func (m *chainMigrationV3) MigrateBackward(data any) (any, error) { +func (m *chainMigrationV3) MigrateBackward(ctx context.Context, data any) (any, error) { s := data.(string) return s + " -> v2", nil } @@ -274,7 +290,10 @@ func Test_VersionChain(t *testing.T) { Address AddressString `json:"address"` } u := User{Address: "start"} - data, err := rm.WithUserVersion(req).Marshal(&u) + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(&u) require.NoError(t, err) var res map[string]interface{} @@ -291,8 +310,359 @@ func Test_VersionChain(t *testing.T) { } jsonData := `{"address": "start"}` var u User - err := rm.WithUserVersion(req).Unmarshal([]byte(jsonData), &u) + migrator, err := rm.For(req) + require.NoError(t, err) + + err = migrator.Unmarshal([]byte(jsonData), &u) require.NoError(t, err) require.Equal(t, AddressString("start -> v2 -> v3"), u.Address) }) } + +type EndpointResponse struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type endpointMigration struct{} + +func (m *endpointMigration) MigrateForward(ctx context.Context, data any) (any, error) { + d, ok := data.(map[string]interface{}) + if !ok { + return data, nil + } + // v1 -> v2: title becomes name + if title, exists := d["title"]; exists { + d["name"] = title + delete(d, "title") + } + return d, nil +} + +func (m *endpointMigration) MigrateBackward(ctx context.Context, data any) (any, error) { + d, ok := data.(map[string]interface{}) + if !ok { + return data, nil + } + // v2 -> v1: name becomes title + if name, exists := d["name"]; exists { + d["title"] = name + delete(d, "name") + } + return d, nil +} + +type PagedResponse struct { + Content interface{} `json:"content"` + Page int `json:"page"` + TotalPages int `json:"total_pages"` +} + +func Test_InterfaceFieldMigration(t *testing.T) { + opts := &RequestMigrationOptions{ + VersionHeader: "X-API-Version", + CurrentVersion: "2024-06-01", + VersionFormat: DateFormat, + } + rm, err := NewRequestMigration(opts) + require.NoError(t, err) + + err = Register[EndpointResponse](rm, "2024-01-01", &endpointMigration{}) + require.NoError(t, err) + + t.Run("Marshal single item in interface field", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-API-Version", "2023-01-01") // older than migration + + wrapper := &PagedResponse{ + Content: EndpointResponse{Name: "test-endpoint", Description: "A test"}, + Page: 1, + TotalPages: 1, + } + + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(wrapper) + require.NoError(t, err) + + var res map[string]interface{} + err = json.Unmarshal(data, &res) + require.NoError(t, err) + + content := res["content"].(map[string]interface{}) + // Should have "title" (old field), not "name" (new field) + require.Equal(t, "test-endpoint", content["title"]) + require.Nil(t, content["name"]) + require.Equal(t, "A test", content["description"]) + }) + + t.Run("Marshal slice in interface field", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-API-Version", "2023-01-01") // older than migration + + wrapper := &PagedResponse{ + Content: []EndpointResponse{ + {Name: "endpoint-1", Description: "First"}, + {Name: "endpoint-2", Description: "Second"}, + }, + Page: 1, + TotalPages: 2, + } + + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(wrapper) + require.NoError(t, err) + + var res map[string]interface{} + err = json.Unmarshal(data, &res) + require.NoError(t, err) + + content := res["content"].([]interface{}) + require.Len(t, content, 2) + + first := content[0].(map[string]interface{}) + require.Equal(t, "endpoint-1", first["title"]) + require.Nil(t, first["name"]) + + second := content[1].(map[string]interface{}) + require.Equal(t, "endpoint-2", second["title"]) + require.Nil(t, second["name"]) + }) + + t.Run("Marshal with current version - no migration", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-API-Version", "2024-06-01") // current version + + wrapper := &PagedResponse{ + Content: EndpointResponse{Name: "test-endpoint", Description: "A test"}, + } + + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(wrapper) + require.NoError(t, err) + + var res map[string]interface{} + err = json.Unmarshal(data, &res) + require.NoError(t, err) + + content := res["content"].(map[string]interface{}) + // Should keep "name" (current version field) + require.Equal(t, "test-endpoint", content["name"]) + require.Nil(t, content["title"]) + }) + + t.Run("Marshal nil interface field", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-API-Version", "2023-01-01") + + wrapper := &PagedResponse{ + Content: nil, + Page: 1, + TotalPages: 0, + } + + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(wrapper) + require.NoError(t, err) + + var res map[string]interface{} + err = json.Unmarshal(data, &res) + require.NoError(t, err) + require.Nil(t, res["content"]) + }) + + t.Run("Marshal unregistered type in interface field", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-API-Version", "2023-01-01") + + // UnregisteredType has no migrations registered + type UnregisteredType struct { + Foo string `json:"foo"` + } + + wrapper := &PagedResponse{ + Content: UnregisteredType{Foo: "bar"}, + } + + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(wrapper) + require.NoError(t, err) + + var res map[string]interface{} + err = json.Unmarshal(data, &res) + require.NoError(t, err) + + content := res["content"].(map[string]interface{}) + // Should pass through unchanged + require.Equal(t, "bar", content["foo"]) + }) +} + +func Test_NestedInterfaceSliceMigration(t *testing.T) { + opts := &RequestMigrationOptions{ + VersionHeader: "X-API-Version", + CurrentVersion: "2024-06-01", + VersionFormat: DateFormat, + } + rm, err := NewRequestMigration(opts) + require.NoError(t, err) + + err = Register[EndpointResponse](rm, "2024-01-01", &endpointMigration{}) + require.NoError(t, err) + + t.Run("Marshal pointer slice in interface field", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-API-Version", "2023-01-01") + + wrapper := &PagedResponse{ + Content: []*EndpointResponse{ + {Name: "endpoint-1", Description: "First"}, + {Name: "endpoint-2", Description: "Second"}, + }, + } + + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(wrapper) + require.NoError(t, err) + + var res map[string]interface{} + err = json.Unmarshal(data, &res) + require.NoError(t, err) + + content := res["content"].([]interface{}) + require.Len(t, content, 2) + + first := content[0].(map[string]interface{}) + require.Equal(t, "endpoint-1", first["title"]) + require.Nil(t, first["name"]) + }) +} + +func Test_NestedPointers(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{}) + + t.Run("Marshal with double pointer", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-Test-Version", "2023-02-01") + + u := &CustomUser{Address: "Main St"} + doublePtr := &u // **CustomUser + + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(doublePtr) + require.NoError(t, err) + + var res map[string]interface{} + json.Unmarshal(data, &res) + require.Equal(t, "Backward: Main St", res["address"]) + }) + + t.Run("Marshal with triple pointer", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-Test-Version", "2023-02-01") + + u := &CustomUser{Address: "Main St"} + doublePtr := &u + triplePtr := &doublePtr // ***CustomUser + + migrator, err := rm.For(req) + require.NoError(t, err) + + data, err := migrator.Marshal(triplePtr) + require.NoError(t, err) + + var res map[string]interface{} + json.Unmarshal(data, &res) + require.Equal(t, "Backward: Main St", res["address"]) + }) + + t.Run("Unmarshal with double pointer", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Add("X-Test-Version", "2023-02-01") + + jsonData := `{"address": "Main St"}` + var u *CustomUser + doublePtr := &u // **CustomUser + + migrator, err := rm.For(req) + require.NoError(t, err) + + err = migrator.Unmarshal([]byte(jsonData), doublePtr) + require.NoError(t, err) + require.NotNil(t, u) + require.Equal(t, AddressString("Migrated: Main St"), u.Address) + }) + + t.Run("Unmarshal with triple pointer", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Add("X-Test-Version", "2023-02-01") + + jsonData := `{"address": "Main St"}` + var u *CustomUser + doublePtr := &u + triplePtr := &doublePtr // ***CustomUser + + migrator, err := rm.For(req) + require.NoError(t, err) + + err = migrator.Unmarshal([]byte(jsonData), triplePtr) + require.NoError(t, err) + require.NotNil(t, u) + require.Equal(t, AddressString("Migrated: Main St"), u.Address) + }) +} + +func Test_ForNilRequest(t *testing.T) { + opts := &RequestMigrationOptions{ + VersionHeader: "X-Test-Version", + CurrentVersion: "2023-03-01", + VersionFormat: DateFormat, + } + rm, err := NewRequestMigration(opts) + require.NoError(t, err) + + t.Run("For returns error on nil request", func(t *testing.T) { + migrator, err := rm.For(nil) + require.Error(t, err) + require.Nil(t, migrator) + require.Equal(t, "request cannot be nil", err.Error()) + }) +} + +func Test_BindAlias(t *testing.T) { + opts := &RequestMigrationOptions{ + VersionHeader: "X-Test-Version", + CurrentVersion: "2023-03-01", + VersionFormat: DateFormat, + } + rm, err := NewRequestMigration(opts) + require.NoError(t, err) + + t.Run("Bind is alias for For", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("X-Test-Version", "2023-02-01") + + migrator, err := rm.Bind(req) + require.NoError(t, err) + require.NotNil(t, migrator) + }) +} diff --git a/util.go b/util.go index 90a1c6a..5417399 100644 --- a/util.go +++ b/util.go @@ -1,6 +1,18 @@ package requestmigrations -import "strings" +import ( + "reflect" + "strings" +) // IsStringEmpty checks if the given string s is empty or not func isStringEmpty(s string) bool { return len(strings.TrimSpace(s)) == 0 } + +// dereferenceToLastPtr dereferences nested pointers down to the last pointer level. +// For example: ***T -> *T, **T -> *T, *T -> *T, T -> T +func dereferenceToLastPtr(t reflect.Type) reflect.Type { + if t.Kind() == reflect.Ptr && t.Elem().Kind() == reflect.Ptr { + return dereferenceToLastPtr(t.Elem()) + } + return t +}