API versioning for Go with automatic request/response migrations
Epoch lets you version your Go APIs the way Stripe does - write your handlers once for the latest version, then define migrations to transform requests and responses for older API versions automatically.
- Type-based routing - Explicit type registration at endpoint setup for predictable migrations
- Flow-based operations - Clear separation: requests go Client→HEAD, responses go HEAD→Client
- Automatic bidirectional - One operation generates both request and response transformations
- Automatic nested discovery - Nested objects and arrays are transformed recursively without manual registration
- Field order preservation - JSON responses maintain original field order using Sonic
- Cycle detection - Built-in validation prevents circular migration dependencies
- Write once - Implement handlers for your latest API version only
- Type-safe - Register types at endpoint setup with compile-time checking
- No duplication - No need to maintain multiple versions of the same endpoint
- Flexible versioning - Support date-based (
2024-01-01), semantic (v1.0.0), or string versions - Gin integration - Drop into existing Gin applications with minimal changes
- High performance - Utilizes ByteDance Sonic for fast JSON processing
go get github.com/astronomer/epochpackage main
import (
"github.com/astronomer/epoch/epoch"
"github.com/gin-gonic/gin"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"` // Added in v2.0.0
}
func main() {
// Define version migration
v1, _ := epoch.NewSemverVersion("1.0.0")
v2, _ := epoch.NewSemverVersion("2.0.0")
migration := epoch.NewVersionChangeBuilder(v1, v2).
Description("Add email to User").
ForType(User{}).
RequestToNextVersion().
AddField("email", "[email protected]").
ResponseToPreviousVersion().
RemoveField("email").
Build()
// Setup Epoch
epochInstance, err := epoch.NewEpoch().
WithVersions(v1, v2).
WithHeadVersion().
WithChanges(migration).
Build()
if err != nil {
panic(err)
}
// Add to Gin
r := gin.Default()
r.Use(epochInstance.Middleware())
// Register endpoints with type information
r.GET("/users/:id", epochInstance.WrapHandler(getUser).Returns(User{}).ToHandlerFunc("GET", "/users/:id"))
r.POST("/users", epochInstance.WrapHandler(createUser).Accepts(User{}).Returns(User{}).ToHandlerFunc("POST", "/users"))
r.Run(":8080")
}
func getUser(c *gin.Context) {
// Always implement for HEAD (latest) version
user := User{ID: 1, Name: "John", Email: "[email protected]"}
c.JSON(200, user)
}
func createUser(c *gin.Context) {
var user User
c.ShouldBindJSON(&user)
c.JSON(201, user)
}What just happened?
- ✅
ForType(User{})explicitly registers which type this migration applies to - ✅
RequestToNextVersion().AddField()handles Client→HEAD transformations - ✅
ResponseToPreviousVersion().RemoveField()handles HEAD→Client transformations - ✅
WrapHandler().Returns(User{})registers the endpoint with type information
Test it:
# v1.0.0 - No email in response
curl http://localhost:8080/users/1 -H "X-API-Version: 1.0.0"
# {"id":1,"name":"John"}
# v2.0.0 - Email included
curl http://localhost:8080/users/1 -H "X-API-Version: 2.0.0"
# {"id":1,"name":"John","email":"[email protected]"}The new framework uses flow-based operations that match the actual migration direction:
When a v1 client sends a request, it needs to be migrated TO the HEAD version:
migration := epoch.NewVersionChangeBuilder(v1, v2).
ForType(User{}).
RequestToNextVersion().
AddField("email", "[email protected]"). // Add field for old clients
RemoveField("deprecated_field"). // Remove deprecated field
RenameField("name", "full_name"). // Rename old field to new
Build()When returning to a v1 client, response needs to be migrated FROM HEAD to v1:
migration := epoch.NewVersionChangeBuilder(v1, v2).
ForType(User{}).
ResponseToPreviousVersion().
RemoveField("email"). // Remove new fields
AddField("old_field", "default"). // Restore old fields
RenameField("full_name", "name"). // Rename back to old name
Build()Request Operations (Client → HEAD):
AddField(name, default)- Add field if missingRemoveField(name)- Remove fieldRenameField(from, to)- Rename fieldCustom(func)- Custom transformation logic
Response Operations (HEAD → Client):
AddField(name, default)- Add field if missingRemoveField(name)- Remove fieldRenameField(from, to)- Rename fieldRemoveFieldIfDefault(name, default)- Conditional removalzCustom(func)- Custom transformation logic
Epoch requires explicit type registration at endpoint setup. When you call ToHandlerFunc(method, path), it immediately registers the endpoint with its type information in Epoch's internal registry.
// Register endpoints with type information (method and path required for immediate registration)
r.GET("/users/:id",
epochInstance.WrapHandler(getUser).
Returns(User{}). // Response type
ToHandlerFunc("GET", "/users/:id")) // Registers endpoint immediately
r.POST("/users",
epochInstance.WrapHandler(createUser).
Accepts(User{}). // Request type
Returns(User{}). // Response type
ToHandlerFunc("POST", "/users")) // Registers endpoint immediately
// Array responses
r.GET("/users",
epochInstance.WrapHandler(listUsers).
Returns([]User{}). // Returns array of Users
ToHandlerFunc("GET", "/users"))Important: The method and path parameters passed to ToHandlerFunc() must match the route being registered. This enables immediate endpoint registration for features like OpenAPI schema generation.
You can migrate multiple types together:
migration := epoch.NewVersionChangeBuilder(v2, v3).
Description("Update User and Product").
ForType(User{}).
ResponseToPreviousVersion().
RenameField("full_name", "name").
ForType(Product{}).
ResponseToPreviousVersion().
RemoveField("currency").
Build()Mix declarative operations with custom logic:
migration := epoch.NewVersionChangeBuilder(v1, v2).
ForType(User{}).
RequestToNextVersion().
AddField("email", "[email protected]").
Custom(func(req *epoch.RequestInfo) error {
// Complex validation or transformation
if email, _ := req.GetFieldString("email"); email == "" {
req.SetField("email", "[email protected]")
}
return nil
}).
Build()Apply transformations to all types:
migration := epoch.NewVersionChangeBuilder(v1, v2).
CustomRequest(func(req *epoch.RequestInfo) error {
// Applies to ALL request types
return nil
}).
CustomResponse(func(resp *epoch.ResponseInfo) error {
// Applies to ALL response types
return nil
}).
ForType(User{}).
ResponseToPreviousVersion().
RemoveField("email").
Build()RequestInfo and ResponseInfo provide convenient methods:
// Field access
hasEmail := req.HasField("email")
email, err := req.GetFieldString("email")
age, err := req.GetFieldInt("age")
price, err := req.GetFieldFloat("price")
// Field modification
req.SetField("email", "[email protected]")
req.DeleteField("old_field")
// Array transformation
err := resp.TransformArrayField("users", func(user *ast.Node) error {
return epoch.DeleteNodeField(user, "internal_field")
})Global AST Helper Functions:
// Direct node manipulation (useful in TransformArrayField callbacks)
epoch.SetNodeField(node, "key", "value")
epoch.DeleteNodeField(node, "key")
epoch.RenameNodeField(node, "old_key", "new_key")
epoch.CopyNodeField(sourceNode, destNode, "key")
// Field access
value, err := epoch.GetNodeFieldString(node, "key")
exists := epoch.HasNodeField(node, "key")
// Type checking
if epoch.IsNodeArray(node) { /* handle array */ }
if epoch.IsNodeObject(node) { /* handle object */ }Epoch automatically detects versions from:
- Headers:
X-API-Version: 2024-01-01(highest priority) - URL path:
/v2024-01-01/usersor/v1/users
If both are present, header takes priority.
Specify major version only:
// Configure: 1.0.0, 1.1.0, 1.2.0, 2.0.0, 2.1.0
r.GET("/api/v1/users", handler) // Routes to latest v1.x (1.2.0)
r.GET("/api/v2/users", handler) // Routes to latest v2.x (2.1.0)curl http://localhost:8080/api/v1/users
# Automatically uses v1.2.0 (latest v1.x)epochInstance, err := epoch.NewEpoch().
// Add versions
WithVersions(v1, v2, v3).
WithDateVersions("2023-01-01", "2024-01-01").
WithSemverVersions("1.0.0", "2.0.0").
WithHeadVersion().
// Add migrations
WithChanges(change1, change2, change3).
// Configure (optional)
WithVersionParameter("X-API-Version").
WithVersionFormat(epoch.VersionFormatDate).
WithDefaultVersion(v1).
Build()cd examples/basic
go run main.goDemonstrates:
- Semantic versioning (v1.0.0, v2.0.0)
- Type registration with
.Returns()and.Accepts() - Flow-based operations (
RequestToNextVersion,ResponseToPreviousVersion) - Simple field addition
cd examples/advanced
go run main.goDemonstrates:
- Date-based versioning
- Multiple models (User, Product, Order)
- Field additions and renames across versions
- Array transformations
- Automatic nested type discovery (arrays and objects)
- Full CRUD operations
- Handler runs at HEAD version - You implement handlers for the latest version only
- Epoch detects requested version - From
X-API-Versionheader or URL path - Request migration - Transforms incoming request: Client Version → HEAD
- Handler executes - With migrated request in HEAD format
- Response migration - Transforms outgoing response: HEAD → Client Version
- Nested types transformed - Automatically discovers and transforms nested objects/arrays recursively
- Client receives - Response in their requested version format
Client (v1) → [v1 Request] → Migration (v1→v2) → [v2 Request] → Handler (v2)
↓
Client (v1) ← [v1 Response] ← Migration (v2→v1) ← [v2 Response] ← Handler (v2)
Always use .Returns() and .Accepts() to register endpoint types:
// ✅ Good
r.GET("/users/:id", epochInstance.WrapHandler(getUser).Returns(User{}).ToHandlerFunc("GET", "/users/:id"))
// ❌ Bad - no type registration
r.GET("/users/:id", epochInstance.WrapHandler(getUser).ToHandlerFunc("GET", "/users/:id"))Keep migrations focused on single types:
// ✅ Good - separate migrations per type
userChange := epoch.NewVersionChangeBuilder(v1, v2).
ForType(User{}).
ResponseToPreviousVersion().RemoveField("email").
Build()
productChange := epoch.NewVersionChangeBuilder(v1, v2).
ForType(Product{}).
ResponseToPreviousVersion().RemoveField("sku").
Build()
// ❌ Avoid - mixing types in operations can be confusingUse operations that match the actual migration direction:
// ✅ Good - clear flow direction
migration := epoch.NewVersionChangeBuilder(v1, v2).
ForType(User{}).
RequestToNextVersion(). // Client → HEAD
AddField("email", "default").
ResponseToPreviousVersion(). // HEAD → Client
RemoveField("email").
Build()# Run all tests
make test-ginkgo
# Or use go test
go test ./epoch/...
# Run with coverage
go test ./epoch/... -coverprofile=coverage.out
go tool cover -html=coverage.out
# Verify examples compile
cd examples/basic && go build
cd examples/advanced && go buildContributions welcome! Please feel free to submit a Pull Request.
MIT License - see LICENSE file for details.
Inspired by Stripe-style API versioning and Cadwyn for Python.