diff --git a/pkg/channels/api_version.go b/pkg/channels/api_version.go new file mode 100644 index 000000000..1a0a1da3e --- /dev/null +++ b/pkg/channels/api_version.go @@ -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 +} diff --git a/pkg/channels/api_version_test.go b/pkg/channels/api_version_test.go new file mode 100644 index 000000000..5bde01084 --- /dev/null +++ b/pkg/channels/api_version_test.go @@ -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") + } +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index a6a565d7f..bb8163195 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -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), }) diff --git a/pkg/channels/version_negotiator.go b/pkg/channels/version_negotiator.go new file mode 100644 index 000000000..95136c01b --- /dev/null +++ b/pkg/channels/version_negotiator.go @@ -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) + }) +}