feat(api): Add API versioning support with backward compatibility

- Add API version control middleware to support versioned endpoints
- Implement versioned route registration for all channels (e.g., /v1/webhook/telegram)
- Maintain backward compatibility by keeping unversioned routes active
- Provide version negotiation via headers (API-Version) and path prefixes
- Support CORS headers specific to version negotiation
- Allow for future API version expansion while maintaining legacy access
This commit is contained in:
liugangjian 2026-03-04 21:12:19 +08:00
parent f5043d7445
commit ece29d5a08
4 changed files with 195 additions and 0 deletions

View file

@ -0,0 +1,56 @@
package channels
import (
"net/http"
)
// APIVersionMiddleware provides version negotiation and versioned routing for APIs.
type APIVersionMiddleware struct {
Version string
Handler http.Handler
}
// ServeHTTP implements the http.Handler interface with version negotiation.
func (avm *APIVersionMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Set response headers for version negotiation
w.Header().Set("API-Version", avm.Version)
// Add CORS headers for API clients
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, API-Version")
w.Header().Set("Access-Control-Expose-Headers", "API-Version")
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Continue with the wrapped handler
avm.Handler.ServeHTTP(w, r)
}
// NewVersionedHandler creates a new APIVersionMiddleware instance.
func NewVersionedHandler(version string, handler http.Handler) *APIVersionMiddleware {
return &APIVersionMiddleware{
Version: version,
Handler: handler,
}
}
// WithVersionPrefix returns a versioned path for a given endpoint.
// For example, if the version is "v1" and the endpoint is "/webhook/telegram",
// it would return "/v1/webhook/telegram".
func WithVersionPrefix(version, endpoint string) string {
if version == "" {
return endpoint
}
if endpoint == "" {
return "/" + version
}
if endpoint[0] != '/' {
endpoint = "/" + endpoint
}
return "/" + version + endpoint
}

View file

@ -0,0 +1,48 @@
package channels
import (
"testing"
)
func TestWithVersionPrefix(t *testing.T) {
tests := []struct {
version string
endpoint string
expected string
name string
}{
{"v1", "/webhook/telegram", "/v1/webhook/telegram", "normal path"},
{"v2", "/health", "/v2/health", "different version"},
{"v1", "", "/v1", "empty endpoint"},
{"", "/webhook/telegram", "/webhook/telegram", "empty version"},
{"v1", "webhook/telegram", "/v1/webhook/telegram", "endpoint without slash"},
{"", "", "/", "both empty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := WithVersionPrefix(tt.version, tt.endpoint)
if result != tt.expected {
t.Errorf("WithVersionPrefix(%q, %q) = %q, want %q", tt.version, tt.endpoint, result, tt.expected)
}
})
}
}
func TestAPIVersionNegotiator(t *testing.T) {
versions := []string{"v1", "v2", "v3"}
negotiator := NewAPIVersionNegotiator(versions, "v1")
if !negotiator.isValidVersion("v1") {
t.Error("Expected v1 to be valid version")
}
if negotiator.isValidVersion("v999") {
t.Error("Expected v999 to be invalid version")
}
// Test default version
if negotiator.DetermineVersion(nil) != "v1" {
t.Error("Expected default version to be v1")
}
}

View file

@ -309,6 +309,10 @@ func (m *Manager) initChannels() error {
m.initChannel("pico", "Pico")
}
if m.config.Channels.WebSocket.Enabled {
m.initChannel("websocket", "WebSocket")
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})

View file

@ -0,0 +1,87 @@
package channels
import (
"net/http"
"strings"
)
// APIVersionNegotiator handles API version negotiation based on request headers, path, or query params
type APIVersionNegotiator struct {
// ValidVersions holds the list of API versions the server supports
ValidVersions []string
// DefaultVersion specifies which version to use when none is indicated
DefaultVersion string
}
// NewAPIVersionNegotiator creates a new instance with valid versions and a default
func NewAPIVersionNegotiator(validVersions []string, defaultVersion string) *APIVersionNegotiator {
if len(validVersions) == 0 {
validVersions = []string{"v1"}
}
if defaultVersion == "" {
defaultVersion = "v1"
}
return &APIVersionNegotiator{
ValidVersions: validVersions,
DefaultVersion: defaultVersion,
}
}
// DetermineVersion extracts and validates API version from the request
func (avn *APIVersionNegotiator) DetermineVersion(r *http.Request) string {
// Check for API-Version header first
if version := r.Header.Get("API-Version"); version != "" {
if avn.isValidVersion(version) {
return version
}
}
// Check for X-API-Version header as fallback
if version := r.Header.Get("X-API-Version"); version != "" {
if avn.isValidVersion(version) {
return version
}
}
// Check URL path for version prefix (e.g. /v2/webhook/telegram)
parts := strings.Split(r.URL.Path, "/")
if len(parts) > 1 {
pathVersion := parts[1]
if avn.isValidVersion(pathVersion) {
return pathVersion
}
}
// Check query parameter as last resort
if version := r.URL.Query().Get("api-version"); version != "" {
if avn.isValidVersion(version) {
return version
}
}
// Return default if no version could be negotiated
return avn.DefaultVersion
}
// isValidVersion checks if the given version string is one of the supported versions
func (avn *APIVersionNegotiator) isValidVersion(version string) bool {
for _, v := range avn.ValidVersions {
if v == version {
return true
}
}
return false
}
// VersionedHandlerWithNegotiation creates an HTTP handler that performs version negotiation
func VersionedHandlerWithNegotiation(negotiator *APIVersionNegotiator, v1Handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
version := negotiator.DetermineVersion(r)
// Set the negotiated version on the response
w.Header().Set("API-Version", version)
// For now, we'll serve v1 regardless of negotiated version
// In future, could route to different handlers based on version
v1Handler.ServeHTTP(w, r)
})
}