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
- 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", "user@example.com").
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())
r.POST("/users", epochInstance.WrapHandler(createUser).Accepts(User{}).Returns(User{}).ToHandlerFunc())
r.Run(":8080")
}
func getUser(c *gin.Context) {
// Always implement for HEAD (latest) version
user := User{ID: 1, Name: "John", Email: "john@example.com"}
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":"john@example.com"}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", "default@example.com"). // 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 removalCustom(func)- Custom transformation logic
Epoch requires explicit type registration at endpoint setup:
// Register endpoints with type information
r.GET("/users/:id",
epochInstance.WrapHandler(getUser).
Returns(User{}). // Response type
ToHandlerFunc())
r.POST("/users",
epochInstance.WrapHandler(createUser).
Accepts(User{}). // Request type
Returns(User{}). // Response type
ToHandlerFunc())
// Array responses
r.GET("/users",
epochInstance.WrapHandler(listUsers).
Returns([]User{}). // Returns array of Users
ToHandlerFunc())
// Nested arrays
r.GET("/orders",
epochInstance.WrapHandler(listOrders).
Returns(OrderResponse{}).
WithArrayItems("items", OrderItem{}). // Nested array field
ToHandlerFunc())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", "default@example.com").
Custom(func(req *epoch.RequestInfo) error {
// Complex validation or transformation
if email, _ := req.GetFieldString("email"); email == "" {
req.SetField("email", "user@example.com")
}
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", "new@example.com")
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
- Nested array migrations with
WithArrayItems() - 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
- 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())
// ❌ Bad - no type registration
r.GET("/users/:id", epochInstance.WrapHandler(getUser))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.