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:
parent
f5043d7445
commit
ece29d5a08
4 changed files with 195 additions and 0 deletions
56
pkg/channels/api_version.go
Normal file
56
pkg/channels/api_version.go
Normal 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
|
||||||
|
}
|
||||||
48
pkg/channels/api_version_test.go
Normal file
48
pkg/channels/api_version_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -309,6 +309,10 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("pico", "Pico")
|
m.initChannel("pico", "Pico")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.WebSocket.Enabled {
|
||||||
|
m.initChannel("websocket", "WebSocket")
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
87
pkg/channels/version_negotiator.go
Normal file
87
pkg/channels/version_negotiator.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue