diff --git a/cmd/soundtouch-web/handlers/handlers.go b/cmd/soundtouch-web/handlers/handlers.go index 4534857..b58572f 100644 --- a/cmd/soundtouch-web/handlers/handlers.go +++ b/cmd/soundtouch-web/handlers/handlers.go @@ -13,6 +13,7 @@ import ( "github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes" "github.com/gesellix/bose-soundtouch/pkg/models" bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx" + "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" ) @@ -61,7 +62,7 @@ func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) { // HandleAPIDevice returns a specific device as JSON func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) { - deviceID := strings.TrimPrefix(r.URL.Path, "/api/device/") + deviceID := chi.URLParam(r, "id") if deviceID == "" { app.sendError(w, "Device ID required", http.StatusBadRequest) return @@ -98,18 +99,9 @@ func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) { // HandleAPIControl handles device control commands func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/api/control/") + deviceID := chi.URLParam(r, "id") + action := chi.URLParam(r, "action") - parts := strings.Split(path, "/") - if len(parts) < 2 { - app.sendError(w, "Invalid control path", http.StatusBadRequest) - return - } - - deviceID := parts[0] - action := parts[1] - - // Check for empty device ID if deviceID == "" { app.sendError(w, "Device ID required", http.StatusBadRequest) return @@ -323,19 +315,8 @@ func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode i // HandleDeviceKey handles sending key commands to devices func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - app.sendError(w, "POST required", http.StatusMethodNotAllowed) - return - } - - pathParts := strings.Split(r.URL.Path, "/") - if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-key" { - app.sendError(w, "Invalid path format", http.StatusBadRequest) - return - } - - deviceID := pathParts[3] - key := pathParts[4] + deviceID := chi.URLParam(r, "id") + key := chi.URLParam(r, "key") device, exists := app.Devices[deviceID] if !exists { @@ -361,20 +342,9 @@ func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) { // HandleDirectVolumeControl handles direct volume setting via URL parameter func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - app.sendError(w, "POST required", http.StatusMethodNotAllowed) - return - } + deviceID := chi.URLParam(r, "id") - pathParts := strings.Split(r.URL.Path, "/") - if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-volume" { - app.sendError(w, "Invalid path format", http.StatusBadRequest) - return - } - - deviceID := pathParts[3] - - volumeLevel, err := strconv.Atoi(pathParts[4]) + volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume")) if err != nil || volumeLevel < 0 || volumeLevel > 100 { app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest) return @@ -404,18 +374,7 @@ func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Requ // HandleDevicePower handles power toggle commands for devices func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - app.sendError(w, "POST required", http.StatusMethodNotAllowed) - return - } - - pathParts := strings.Split(r.URL.Path, "/") - if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power" { - app.sendError(w, "Invalid path format", http.StatusBadRequest) - return - } - - deviceID := pathParts[3] + deviceID := chi.URLParam(r, "id") device, exists := app.Devices[deviceID] if !exists { @@ -442,18 +401,7 @@ func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) { // HandleDevicePowerStatus handles lightweight power status check func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - app.sendError(w, "GET required", http.StatusMethodNotAllowed) - return - } - - pathParts := strings.Split(r.URL.Path, "/") - if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power-status" { - app.sendError(w, "Invalid path format", http.StatusBadRequest) - return - } - - deviceID := pathParts[3] + deviceID := chi.URLParam(r, "id") device, exists := app.Devices[deviceID] if !exists { @@ -587,14 +535,7 @@ func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) { // - /sub/{n}/{encodedURI} → single subsection // - /profiles/{type}/{id}/{encodedURI} → artist/program profile func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) { - const navPrefix = "/api/tunein/navigate" - - path := r.URL.Path - wildcard := "" - - if len(path) > len(navPrefix) { - wildcard = strings.TrimPrefix(path[len(navPrefix):], "/") - } + wildcard := chi.URLParam(r, "*") var ( resp interface{} @@ -651,7 +592,7 @@ func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) // HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select. func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) { - deviceID := strings.TrimPrefix(r.URL.Path, "/api/tunein/play/") + deviceID := chi.URLParam(r, "id") if deviceID == "" { app.sendError(w, "Device ID required", http.StatusBadRequest) return diff --git a/cmd/soundtouch-web/handlers/handlers_test.go b/cmd/soundtouch-web/handlers/handlers_test.go index b000544..cb4c5cf 100644 --- a/cmd/soundtouch-web/handlers/handlers_test.go +++ b/cmd/soundtouch-web/handlers/handlers_test.go @@ -2,6 +2,7 @@ package handlers import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -12,6 +13,7 @@ import ( "github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes" "github.com/gesellix/bose-soundtouch/pkg/client" "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/go-chi/chi/v5" ) func createTestApp() *WebApp { @@ -42,6 +44,14 @@ func createTestApp() *WebApp { return app } +func withChiParams(r *http.Request, params map[string]string) *http.Request { + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + func TestNewWebApp(t *testing.T) { app := NewWebApp() @@ -102,24 +112,28 @@ func TestHandleAPIDevice(t *testing.T) { tests := []struct { name string path string + chiID string expectedStatus int expectSuccess bool }{ { name: "valid device", path: "/api/device/test-device", + chiID: "test-device", expectedStatus: http.StatusOK, expectSuccess: true, }, { name: "missing device ID", path: "/api/device/", + chiID: "", expectedStatus: http.StatusBadRequest, expectSuccess: false, }, { name: "unknown device", path: "/api/device/unknown", + chiID: "unknown", expectedStatus: http.StatusNotFound, expectSuccess: false, }, @@ -128,6 +142,9 @@ func TestHandleAPIDevice(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest("GET", tt.path, nil) + if tt.chiID != "" { + req = withChiParams(req, map[string]string{"id": tt.chiID}) + } w := httptest.NewRecorder() app.HandleAPIDevice(w, req) @@ -157,6 +174,7 @@ func TestHandleAPIControl_InvalidDevice(t *testing.T) { app := createTestApp() req := httptest.NewRequest("GET", "/api/control/unknown-device/play", nil) + req = withChiParams(req, map[string]string{"id": "unknown-device", "action": "play"}) w := httptest.NewRecorder() app.HandleAPIControl(w, req) @@ -261,6 +279,7 @@ func TestHandleAPIControl_VolumeValidation(t *testing.T) { } else { req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", nil) } + req = withChiParams(req, map[string]string{"id": "test-device", "action": "volume"}) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -311,6 +330,7 @@ func TestHandleAPIControl_BassValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(tt.method, "/api/control/test-device/bass", strings.NewReader(tt.body)) + req = withChiParams(req, map[string]string{"id": "test-device", "action": "bass"}) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -358,6 +378,7 @@ func TestHandleAPIControl_PresetValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest("GET", "/api/control/test-device/preset"+tt.query, nil) + req = withChiParams(req, map[string]string{"id": "test-device", "action": "preset"}) w := httptest.NewRecorder() app.HandleAPIControl(w, req) @@ -382,6 +403,7 @@ func TestHandleAPIControl_SourceValidation(t *testing.T) { app := createTestApp() req := httptest.NewRequest("GET", "/api/control/test-device/source", nil) + req = withChiParams(req, map[string]string{"id": "test-device", "action": "source"}) w := httptest.NewRecorder() app.HandleAPIControl(w, req) @@ -497,6 +519,7 @@ func TestHandleAPIControl_UnsupportedAction(t *testing.T) { app := createTestApp() req := httptest.NewRequest("GET", "/api/control/test-device/unsupported", nil) + req = withChiParams(req, map[string]string{"id": "test-device", "action": "unsupported"}) w := httptest.NewRecorder() app.HandleAPIControl(w, req) @@ -545,6 +568,7 @@ func BenchmarkHandleAPIDevices(b *testing.B) { func BenchmarkHandleAPIDevice(b *testing.B) { app := createTestApp() req := httptest.NewRequest("GET", "/api/device/test-device", nil) + req = withChiParams(req, map[string]string{"id": "test-device"}) b.ResetTimer() for i := 0; i < b.N; i++ { diff --git a/cmd/soundtouch-web/handlers/websocket.go b/cmd/soundtouch-web/handlers/websocket.go index 33c736a..e42b190 100644 --- a/cmd/soundtouch-web/handlers/websocket.go +++ b/cmd/soundtouch-web/handlers/websocket.go @@ -5,11 +5,11 @@ import ( "encoding/json" "log" "net/http" - "strings" "time" "github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes" "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/go-chi/chi/v5" "github.com/gorilla/websocket" ) @@ -224,13 +224,7 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) // HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) { - pathParts := strings.Split(r.URL.Path, "/") - if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-ws" { - http.Error(w, "Invalid path format", http.StatusBadRequest) - return - } - - deviceID := pathParts[3] + deviceID := chi.URLParam(r, "id") if deviceID == "" { http.Error(w, "Device ID required", http.StatusBadRequest) return diff --git a/cmd/soundtouch-web/main.go b/cmd/soundtouch-web/main.go index 6612960..c499720 100644 --- a/cmd/soundtouch-web/main.go +++ b/cmd/soundtouch-web/main.go @@ -3,10 +3,11 @@ package main import ( "context" + "embed" "flag" + "io/fs" "log" "net/http" - "os" "time" "github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers" @@ -14,8 +15,12 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/client" "github.com/gesellix/bose-soundtouch/pkg/config" "github.com/gesellix/bose-soundtouch/pkg/discovery" + "github.com/go-chi/chi/v5" ) +//go:embed static +var staticFS embed.FS + var ( port = flag.String("port", "8080", "Web server port") _ = flag.String("host", "", "Specific SoundTouch device host (optional)") @@ -56,29 +61,35 @@ func main() { }() // Setup HTTP routes - setupRoutes(app, discoveryService) + r := setupRoutes(app, discoveryService) // Start web server log.Printf("SoundTouch Web UI starting on http://localhost:%s", *port) - log.Fatal(http.ListenAndServe(":"+*port, nil)) + log.Fatal(http.ListenAndServe(":"+*port, r)) } -func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) { - // Static files - try both relative paths - staticDir := "cmd/soundtouch-web/static/" - if _, err := os.Stat(staticDir); os.IsNotExist(err) { - staticDir = "static/" +func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux { + r := chi.NewRouter() + + // Static assets (embedded in binary) + subFS, _ := fs.Sub(staticFS, "static") + r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP) + + // Serve index.html for SPA routes + serveIndex := func(w http.ResponseWriter, _ *http.Request) { + data, _ := staticFS.ReadFile("static/index.html") + + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write(data) } - http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))) - // WebSocket endpoint - http.HandleFunc("/ws", app.HandleWebSocket) + r.Get("/ws", app.HandleWebSocket) // API endpoints - http.HandleFunc("/api/devices", app.HandleAPIDevices) - http.HandleFunc("/api/device/", app.HandleAPIDevice) - http.HandleFunc("/api/discover", func(w http.ResponseWriter, r *http.Request) { + r.Get("/api/devices", app.HandleAPIDevices) + r.Get("/api/device/{id}", app.HandleAPIDevice) + r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) { app.HandleAPIDiscover(w, r) // Trigger discovery //nolint:contextcheck // Context is created within goroutine @@ -97,39 +108,29 @@ func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscov }() }) - // Device control endpoints - http.HandleFunc("/api/control/", app.HandleAPIControl) + // Device control endpoints (GET for most actions, POST for volume/bass) + r.Get("/api/control/{id}/{action}", app.HandleAPIControl) + r.Post("/api/control/{id}/{action}", app.HandleAPIControl) // TuneIn browse, search, and playback - http.HandleFunc("/api/tunein/search", app.HandleTuneInSearch) - http.HandleFunc("/api/tunein/navigate", app.HandleTuneInNavigate) - http.HandleFunc("/api/tunein/navigate/", app.HandleTuneInNavigate) - http.HandleFunc("/api/tunein/play/", app.HandlePlayTuneIn) + r.Get("/api/tunein/search", app.HandleTuneInSearch) + r.Get("/api/tunein/navigate", app.HandleTuneInNavigate) + r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate) + r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn) - // Enhanced device control endpoints with specific patterns - http.HandleFunc("/api/device-key/", app.HandleDeviceKey) - http.HandleFunc("/api/device-volume/", app.HandleDirectVolumeControl) - http.HandleFunc("/api/device-power/", app.HandleDevicePower) - http.HandleFunc("/api/device-power-status/", app.HandleDevicePowerStatus) - http.HandleFunc("/api/device-ws/", app.HandleDeviceWebSocket) + // Enhanced device control endpoints + r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey) + r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl) + r.Post("/api/device-power/{id}", app.HandleDevicePower) + r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus) + r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket) - // SPA routes - serve index.html for specific routes only - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // Serve the SPA index.html file for root path - spaPath := staticDir + "index.html" - http.ServeFile(w, r, spaPath) - }) + // SPA routes - serve index.html for client-side routing + r.Get("/", serveIndex) + r.Get("/devices", serveIndex) + r.Get("/device/*", serveIndex) - // Additional SPA routes for client-side routing - http.HandleFunc("/devices", func(w http.ResponseWriter, r *http.Request) { - spaPath := staticDir + "index.html" - http.ServeFile(w, r, spaPath) - }) - - http.HandleFunc("/device/", func(w http.ResponseWriter, r *http.Request) { - spaPath := staticDir + "index.html" - http.ServeFile(w, r, spaPath) - }) + return r } func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) { diff --git a/cmd/soundtouch-web/spa_test.go b/cmd/soundtouch-web/spa_test.go index d74755b..2231d3b 100644 --- a/cmd/soundtouch-web/spa_test.go +++ b/cmd/soundtouch-web/spa_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -11,8 +12,17 @@ import ( "github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers" "github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes" "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/go-chi/chi/v5" ) +func withChiParams(r *http.Request, params map[string]string) *http.Request { + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + func TestSPARouting(t *testing.T) { tests := []struct { name string @@ -134,6 +144,8 @@ func TestAPIEndpoints(t *testing.T) { app.HandleAPIDiscover(w, req) default: if strings.HasPrefix(tt.path, "/api/device/") { + deviceID := strings.TrimPrefix(tt.path, "/api/device/") + req = withChiParams(req, map[string]string{"id": deviceID}) app.HandleAPIDevice(w, req) } } @@ -200,6 +212,7 @@ func TestControlAPIValidation(t *testing.T) { method string body string expectedStatus int + chiParams map[string]string }{ { name: "missing device ID", @@ -218,18 +231,21 @@ func TestControlAPIValidation(t *testing.T) { path: "/api/control/nonexistent/invalid", method: "GET", expectedStatus: http.StatusNotFound, + chiParams: map[string]string{"id": "nonexistent", "action": "invalid"}, }, { name: "nonexistent device", path: "/api/control/nonexistent/play", method: "GET", expectedStatus: http.StatusNotFound, + chiParams: map[string]string{"id": "nonexistent", "action": "play"}, }, { name: "unknown action with valid device", path: "/api/control/testdevice/unknownaction", method: "GET", expectedStatus: http.StatusBadRequest, + chiParams: map[string]string{"id": "testdevice", "action": "unknownaction"}, }, } @@ -251,6 +267,9 @@ func TestControlAPIValidation(t *testing.T) { } else { req = httptest.NewRequest(tt.method, tt.path, nil) } + if tt.chiParams != nil { + req = withChiParams(req, tt.chiParams) + } w := httptest.NewRecorder() app.HandleAPIControl(w, req) @@ -319,6 +338,8 @@ func TestJSONAPIConsistency(t *testing.T) { app.HandleAPIDevices(w, req) default: if strings.HasPrefix(endpoint, "/api/device/") { + deviceID := strings.TrimPrefix(endpoint, "/api/device/") + req = withChiParams(req, map[string]string{"id": deviceID}) app.HandleAPIDevice(w, req) } }