Echo API Versioning
In the ever-evolving world of API development, changes to your API are inevitable. As your application grows, you'll need to add features, fix bugs, and sometimes make breaking changes to your API endpoints. This is where API versioning becomes crucial.
What is API Versioning?
API versioning is the practice of managing changes to your API while maintaining backward compatibility with existing clients. It allows you to evolve your API without disrupting applications that rely on its current functionality.
When you implement versioning in your Echo API:
- Existing clients can continue to use the older version
- New clients can adopt the newer version with enhanced features
- You can deprecate and eventually remove outdated versions
Why Version Your Echo APIs?
- Backward Compatibility: Ensures existing clients continue to function as expected
- Smooth Transitions: Provides a migration path for clients to newer versions
- Parallel Development: Allows you to work on new features without affecting current users
- Reliability: Creates trust with API consumers who know your changes won't break their applications
Common API Versioning Strategies in Echo
Let's explore the most common approaches to versioning your Echo APIs.
1. URL Path Versioning
This is perhaps the most straightforward approach where the version is included in the URL path.
// main.go
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
// API v1
v1 := e.Group("/api/v1")
v1.GET("/users", getUsersV1)
// API v2
v2 := e.Group("/api/v2")
v2.GET("/users", getUsersV2)
e.Start(":8080")
}
// Handler for API v1
func getUsersV1(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"version": "v1",
"users": []string{"Alice", "Bob"},
})
}
// Handler for API v2
func getUsersV2(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"version": "v2",
"users": []map[string]interface{}{
{"name": "Alice", "role": "Admin"},
{"name": "Bob", "role": "User"},
},
})
}
Example requests and responses:
Request to v1:
GET /api/v1/users HTTP/1.1
Host: example.com
Response from v1:
{
"version": "v1",
"users": ["Alice", "Bob"]
}
Request to v2:
GET /api/v2/users HTTP/1.1
Host: example.com
Response from v2:
{
"version": "v2",
"users": [
{"name": "Alice", "role": "Admin"},
{"name": "Bob", "role": "User"}
]
}
2. Query Parameter Versioning
Another approach is to use query parameters to specify the API version.
// main.go
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/api/users", getUsers)
e.Start(":8080")
}
func getUsers(c echo.Context) error {
version := c.QueryParam("version")
if version == "v2" {
return c.JSON(http.StatusOK, map[string]interface{}{
"version": "v2",
"users": []map[string]interface{}{
{"name": "Alice", "role": "Admin"},
{"name": "Bob", "role": "User"},
},
})
}
// Default to v1
return c.JSON(http.StatusOK, map[string]interface{}{
"version": "v1",
"users": []string{"Alice", "Bob"},
})
}
Example requests and responses:
Request to v1:
GET /api/users HTTP/1.1
Host: example.com
Request to v2:
GET /api/users?version=v2 HTTP/1.1
Host: example.com
3. Header-Based Versioning
This approach uses HTTP headers to determine the API version.
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/api/users", getUsers)
e.Start(":8080")
}
func getUsers(c echo.Context) error {
version := c.Request().Header.Get("Accept-Version")
if version == "v2" {
return c.JSON(http.StatusOK, map[string]interface{}{
"version": "v2",
"users": []map[string]interface{}{
{"name": "Alice", "role": "Admin"},
{"name": "Bob", "role": "User"},
},
})
}
// Default to v1
return c.JSON(http.StatusOK, map[string]interface{}{
"version": "v1",
"users": []string{"Alice", "Bob"},
})
}
Example requests:
Request to v1:
GET /api/users HTTP/1.1
Host: example.com
Request to v2:
GET /api/users HTTP/1.1
Host: example.com
Accept-Version: v2
4. Content Negotiation
Using the Accept header for content negotiation:
package main
import (
"net/http"
"strings"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/api/users", getUsers)
e.Start(":8080")
}
func getUsers(c echo.Context) error {
accept := c.Request().Header.Get("Accept")
if strings.Contains(accept, "application/vnd.myapi.v2+json") {
return c.JSON(http.StatusOK, map[string]interface{}{
"version": "v2",
"users": []map[string]interface{}{
{"name": "Alice", "role": "Admin"},
{"name": "Bob", "role": "User"},
},
})
}
// Default to v1
return c.JSON(http.StatusOK, map[string]interface{}{
"version": "v1",
"users": []string{"Alice", "Bob"},
})
}
Example requests:
Request to v1:
GET /api/users HTTP/1.1
Host: example.com
Accept: application/json
Request to v2:
GET /api/users HTTP/1.1
Host: example.com
Accept: application/vnd.myapi.v2+json
Best Practices for Echo API Versioning
Here are some best practices to follow when implementing API versioning in your Echo applications:
1. Choose a Consistent Versioning Strategy
Pick one versioning strategy and use it consistently across your API. Mixing different versioning methods can confuse API consumers.
2. Use Semantic Versioning for Your API
Consider adopting semantic versioning principles:
- Major version changes (v1 to v2) for breaking changes
- Minor version changes (v1.1 to v1.2) for backward-compatible functionality additions
- Patch version changes (v1.1.1 to v1.1.2) for backward-compatible bug fixes
3. Create Version-Specific Packages
Organize your code with version-specific packages to maintain clean separation:
/api
/v1
handlers.go
models.go
/v2
handlers.go
models.go
// main.go
package main
import (
"github.com/labstack/echo/v4"
"myapp/api/v1"
"myapp/api/v2"
)
func main() {
e := echo.New()
// Register API v1 routes
v1Group := e.Group("/api/v1")
v1.RegisterRoutes(v1Group)
// Register API v2 routes
v2Group := e.Group("/api/v2")
v2.RegisterRoutes(v2Group)
e.Start(":8080")
}
4. Document API Versions
Clearly document each version of your API, including:
- Available endpoints
- Expected request/response formats
- Deprecation schedules
- Migration guides
5. Set a Deprecation Policy
Communicate how long older API versions will be supported before being deprecated and eventually retired.
func getUsersV1(c echo.Context) error {
// Add deprecation warning header
c.Response().Header().Set("Warning", "299 - \"This API version is deprecated and will be removed on 2023-12-31. Please migrate to v2.\"")
return c.JSON(http.StatusOK, map[string]interface{}{
"version": "v1",
"users": []string{"Alice", "Bob"},
})
}
Real-World Example: Building a Versioned Todo API
Let's build a complete example of a versioned Todo API using Echo.
// main.go
package main
import (
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// Todo model for v1
type TodoV1 struct {
ID int `json:"id"`
Task string `json:"task"`
Done bool `json:"done"`
}
// Todo model for v2 (adds creation date)
type TodoV2 struct {
ID int `json:"id"`
Task string `json:"task"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
// Simple in-memory storage
var todosV1 = []TodoV1{
{ID: 1, Task: "Learn Echo", Done: false},
{ID: 2, Task: "Build API", Done: false},
}
var todosV2 = []TodoV2{
{ID: 1, Task: "Learn Echo", Done: false, CreatedAt: time.Now().Add(-24 * time.Hour)},
{ID: 2, Task: "Build API", Done: false, CreatedAt: time.Now()},
}
func main() {
e := echo.New()
// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// API v1 routes
v1 := e.Group("/api/v1")
v1.GET("/todos", getTodosV1)
v1.POST("/todos", createTodoV1)
v1.GET("/todos/:id", getTodoV1)
// API v2 routes
v2 := e.Group("/api/v2")
v2.GET("/todos", getTodosV2)
v2.POST("/todos", createTodoV2)
v2.GET("/todos/:id", getTodoV2)
e.Start(":8080")
}
// V1 API handlers
func getTodosV1(c echo.Context) error {
return c.JSON(http.StatusOK, todosV1)
}
func createTodoV1(c echo.Context) error {
todo := new(TodoV1)
if err := c.Bind(todo); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
todo.ID = len(todosV1) + 1
todosV1 = append(todosV1, *todo)
return c.JSON(http.StatusCreated, todo)
}
func getTodoV1(c echo.Context) error {
id := c.Param("id")
for _, todo := range todosV1 {
if string(todo.ID) == id {
return c.JSON(http.StatusOK, todo)
}
}
return echo.NewHTTPError(http.StatusNotFound, "Todo not found")
}
// V2 API handlers
func getTodosV2(c echo.Context) error {
return c.JSON(http.StatusOK, todosV2)
}
func createTodoV2(c echo.Context) error {
todo := new(TodoV2)
if err := c.Bind(todo); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
todo.ID = len(todosV2) + 1
todo.CreatedAt = time.Now()
todosV2 = append(todosV2, *todo)
return c.JSON(http.StatusCreated, todo)
}
func getTodoV2(c echo.Context) error {
id := c.Param("id")
for _, todo := range todosV2 {
if string(todo.ID) == id {
return c.JSON(http.StatusOK, todo)
}
}
return echo.NewHTTPError(http.StatusNotFound, "Todo not found")
}
Handling Version Transitions
When transitioning between API versions, consider these approaches:
1. Progressive Enhancement
Introduce new fields while maintaining compatibility with older versions:
type Response struct {
// Fields common to all versions
ID int `json:"id"`
Name string `json:"name"`
// V2 fields
Details *Details `json:"details,omitempty"` // omitempty ensures backward compatibility
}
type Details struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func handleRequest(c echo.Context) error {
version := c.Request().Header.Get("Accept-Version")
resp := Response{
ID: 1,
Name: "Example",
}
if version == "v2" {
resp.Details = &Details{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
return c.JSON(http.StatusOK, resp)
}
2. Proxy/Adapter Pattern
Create adapters that transform responses between versions:
func proxyToNewAPI(c echo.Context) error {
// Get data from new API
newData := fetchFromNewAPI()
// Transform to old format for backward compatibility
oldFormatData := transformToOldFormat(newData)
return c.JSON(http.StatusOK, oldFormatData)
}
Summary
API versioning is a crucial aspect of API design that enables you to evolve your API while maintaining backward compatibility. Echo makes it easy to implement various versioning strategies:
- URL path versioning (
/api/v1/resource
,/api/v2/resource
) - Query parameter versioning (
/api/resource?version=v1
) - Header-based versioning (using custom headers)
- Content negotiation (using Accept headers)
When choosing a versioning strategy, consider:
- The needs of your API consumers
- The complexity of implementation
- The expected frequency of breaking changes
- Industry standards and best practices for your domain
By implementing proper API versioning in your Echo applications, you can confidently make improvements to your API without disrupting existing clients.
Additional Resources
- Echo Framework Documentation
- RESTful API Design Best Practices
- Semantic Versioning Specification
- Microsoft API Guidelines
Practice Exercises
-
Implement a simple blog API with two versions:
- V1: Basic posts with title and content
- V2: Enhanced posts with title, content, author, and tags
-
Add a deprecation policy to V1 of your API that warns users to upgrade to V2 by a certain date.
-
Create a middleware that automatically handles API versioning based on an "API-Version" header.
-
Build an API versioning strategy that supports multiple minor versions within a major version (e.g., v1.1, v1.2).
-
Implement a "sunset" middleware that automatically returns a 410 Gone status for API versions that are no longer supported.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)