From a91f271c210938b5817eafbe434d9d2681f0cc0a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 17 May 2026 20:57:12 +0300 Subject: Add /api/v1/ versioning alias --- player-server/internal/api/handlers_more_test.go | 21 +++-- player-server/internal/api/handlers_test.go | 61 +++++++++----- player-server/internal/api/middleware.go | 4 +- player-server/internal/api/server.go | 102 +++++++++++++---------- 4 files changed, 115 insertions(+), 73 deletions(-) (limited to 'player-server/internal/api') diff --git a/player-server/internal/api/handlers_more_test.go b/player-server/internal/api/handlers_more_test.go index da094ac..451e911 100644 --- a/player-server/internal/api/handlers_more_test.go +++ b/player-server/internal/api/handlers_more_test.go @@ -338,14 +338,19 @@ func TestServer_Login_negativePaths(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24} t.Run("invalid json", func(t *testing.T) { - authSvc := &service.MockAuthService{} - srv := newTestServer(t, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil) - req := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewReader([]byte(`bad`))) - req.Header.Set("Content-Type", "application/json") - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - if rr.Code != http.StatusBadRequest { - t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) + paths := []string{"/api/login", "/api/v1/auth/login"} + for _, path := range paths { + t.Run(path, func(t *testing.T) { + authSvc := &service.MockAuthService{} + srv := newTestServer(t, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`bad`))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) + } + }) } }) diff --git a/player-server/internal/api/handlers_test.go b/player-server/internal/api/handlers_test.go index 1041f2f..e900886 100644 --- a/player-server/internal/api/handlers_test.go +++ b/player-server/internal/api/handlers_test.go @@ -128,8 +128,10 @@ func TestMiddleware_BootstrapRedirect(t *testing.T) { }{ {"public bootstrap html", "/bootstrap.html", 0, nil, http.StatusOK, ""}, {"public api bootstrap", "/api/bootstrap", 0, nil, http.StatusOK, ""}, + {"public api v1 auth bootstrap", "/api/v1/auth/bootstrap", 0, nil, http.StatusOK, ""}, {"public login html", "/login.html", 0, nil, http.StatusOK, ""}, {"public api login", "/api/login", 0, nil, http.StatusOK, ""}, + {"public api v1 auth login", "/api/v1/auth/login", 0, nil, http.StatusOK, ""}, {"public healthz", "/healthz", 0, nil, http.StatusOK, ""}, {"public readyz", "/readyz", 0, nil, http.StatusOK, ""}, {"protected no users", "/", 0, nil, http.StatusTemporaryRedirect, "/bootstrap.html"}, @@ -399,14 +401,19 @@ func TestServer_Bootstrap(t *testing.T) { }) t.Run("missing fields", func(t *testing.T) { - store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }}} - authSvc := service.NewAuthService(store, clk, hasher, nil) - srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil) - req := httptest.NewRequest(http.MethodPost, "/api/bootstrap", bytes.NewReader([]byte(`{"username":""}`))) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - if rr.Code != http.StatusBadRequest { - t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) + paths := []string{"/api/bootstrap", "/api/v1/auth/bootstrap"} + for _, path := range paths { + t.Run(path, func(t *testing.T) { + store := &repository.MockStore{UserRepo: repository.MockUserRepo{CountUsersFunc: func(ctx context.Context) (int, error) { return 0, nil }}} + authSvc := service.NewAuthService(store, clk, hasher, nil) + srv := newTestServer(t, store, hasher, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil, authSvc, nil) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{"username":""}`))) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) + } + }) } }) @@ -1375,19 +1382,31 @@ func TestServer_ListSets(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24} srv := newTestServer(t, buildCountStore(1), nil, sm, cfg, ms, ms, ms, ms, ms, ms, nil, nil, nil, nil) - req := httptest.NewRequest(http.MethodGet, "/api/sets", nil) - req.AddCookie(addSessionCookie(t, store, sm, 1)) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) - } - var sets []model.Set - if err := json.Unmarshal(rr.Body.Bytes(), &sets); err != nil { - t.Fatalf("unmarshal response: %v", err) - } - if len(sets) != 1 || sets[0].Name != "music" { - t.Fatalf("unexpected sets response") + var legacyBody []byte + for _, path := range []string{"/api/sets", "/api/v1/sets"} { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + var sets []model.Set + if err := json.Unmarshal(rr.Body.Bytes(), &sets); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(sets) != 1 || sets[0].Name != "music" { + t.Fatalf("unexpected sets response") + } + if path == "/api/sets" { + legacyBody = append([]byte(nil), rr.Body.Bytes()...) + return + } + if !bytes.Equal(legacyBody, rr.Body.Bytes()) { + t.Fatalf("versioned response differs from legacy response") + } + }) } } diff --git a/player-server/internal/api/middleware.go b/player-server/internal/api/middleware.go index 8fcf1e3..5585be9 100644 --- a/player-server/internal/api/middleware.go +++ b/player-server/internal/api/middleware.go @@ -105,7 +105,9 @@ func (mw *Middleware) BootstrapRedirect(next http.Handler) http.Handler { func isBootstrapPublic(path string) bool { switch path { - case "/bootstrap.html", "/api/bootstrap", "/login.html", "/api/login", "/healthz", "/readyz", + case "/bootstrap.html", "/api/bootstrap", "/api/v1/auth/bootstrap", + "/login.html", "/api/login", "/api/v1/auth/login", + "/healthz", "/readyz", "/favicon.svg", "/favicon.ico", "/logo.svg", "/logo.png", "/manifest.json", "/sw.js": return true } diff --git a/player-server/internal/api/server.go b/player-server/internal/api/server.go index 7a816c2..abcbafd 100644 --- a/player-server/internal/api/server.go +++ b/player-server/internal/api/server.go @@ -5,6 +5,7 @@ import ( "log/slog" "net/http" "strconv" + "strings" "time" "codeberg.org/snonux/player/internal" @@ -130,10 +131,25 @@ func publicMethod(method string, handler http.HandlerFunc) http.HandlerFunc { } } +func (s *Server) handleBoth(method, path string, h http.Handler) { + s.mux.Handle(method+" "+path, h) + s.mux.Handle(method+" "+apiV1Path(path), h) +} + +func apiV1Path(path string) string { + const apiPrefix = "/api/" + if !strings.HasPrefix(path, apiPrefix) { + panic("apiV1Path: path must start with /api/") + } + return "/api/v1/" + strings.TrimPrefix(path, apiPrefix) +} + // routesPublic wires the fully-public API endpoints (bootstrap, login, probes). func (s *Server) routesPublic() { s.mux.HandleFunc("/api/bootstrap", publicMethod(http.MethodPost, s.handleBootstrap)) + s.mux.HandleFunc("/api/v1/auth/bootstrap", publicMethod(http.MethodPost, s.handleBootstrap)) s.mux.HandleFunc("/api/login", publicMethod(http.MethodPost, s.handleLogin)) + s.mux.HandleFunc("/api/v1/auth/login", publicMethod(http.MethodPost, s.handleLogin)) s.mux.HandleFunc("/healthz", publicMethod(http.MethodGet, s.handleHealthz)) s.mux.HandleFunc("/readyz", publicMethod(http.MethodGet, s.handleReadyz)) } @@ -170,72 +186,72 @@ func (s *Server) routesHTML() { // routesAuth wires the logout route. func (s *Server) routesAuth() { - s.mux.Handle("POST /api/logout", s.requireSession(s.handleLogout)) + s.handleBoth(http.MethodPost, "/api/logout", s.requireSession(s.handleLogout)) } // routesConfig wires authenticated client configuration. func (s *Server) routesConfig() { - s.mux.Handle("GET /api/config", s.requireSession(s.handleConfig)) + s.handleBoth(http.MethodGet, "/api/config", s.requireSession(s.handleConfig)) } // routesSets wires the set-related API routes. func (s *Server) routesSets() { - s.mux.Handle("GET /api/sets", s.requireSession(s.handleListSets)) - s.mux.Handle("GET /api/sets/{id}/browse", s.requireSession(s.handleBrowseSet)) - s.mux.Handle("GET /api/sets/{id}/cover", s.requireSession(s.handleGetSetCover)) - s.mux.Handle("POST /api/sets/{id}/cover", s.requireSession(s.handlePostSetCover)) - s.mux.Handle("POST /api/sets/{id}/upload", s.requireSession(s.handleUpload)) + s.handleBoth(http.MethodGet, "/api/sets", s.requireSession(s.handleListSets)) + s.handleBoth(http.MethodGet, "/api/sets/{id}/browse", s.requireSession(s.handleBrowseSet)) + s.handleBoth(http.MethodGet, "/api/sets/{id}/cover", s.requireSession(s.handleGetSetCover)) + s.handleBoth(http.MethodPost, "/api/sets/{id}/cover", s.requireSession(s.handlePostSetCover)) + s.handleBoth(http.MethodPost, "/api/sets/{id}/upload", s.requireSession(s.handleUpload)) } // routesMedia wires the media-related API routes. func (s *Server) routesMedia() { - s.mux.Handle("GET /api/media", s.requireSession(s.handleListMedia)) - s.mux.Handle("GET /api/media/{id}", s.requireSession(s.handleGetMedia)) - s.mux.Handle("GET /api/media/{id}/stream", s.requireSession(s.handleStream)) - s.mux.Handle("GET /api/media/{id}/download", s.requireSession(s.handleDownload)) - s.mux.Handle("GET /api/media/{id}/thumbnail", s.requireSession(s.handleThumbnail)) - s.mux.Handle("POST /api/media/{id}/thumbnail", s.requireSession(s.handleRegenThumbnail)) - s.mux.Handle("POST /api/media/{id}/favorite", s.requireSession(s.handleFavorite)) - s.mux.Handle("GET /api/tags", s.requireSession(s.handleListTags)) - s.mux.Handle("POST /api/media/{id}/tags", s.requireSession(s.handleAddTag)) - s.mux.Handle("DELETE /api/media/{id}/tags/{tag}", s.requireSession(s.handleRemoveTag)) - s.mux.Handle("DELETE /api/media/{id}", s.requireSession(s.handleSoftDelete)) - s.mux.Handle("POST /api/media/{id}/restore", s.requireSession(s.handleRestore)) - s.mux.Handle("POST /api/media/{id}/shares", s.requireSession(s.handleCreateShare)) - s.mux.Handle("GET /api/media/{id}/shares", s.requireSession(s.handleListShares)) + s.handleBoth(http.MethodGet, "/api/media", s.requireSession(s.handleListMedia)) + s.handleBoth(http.MethodGet, "/api/media/{id}", s.requireSession(s.handleGetMedia)) + s.handleBoth(http.MethodGet, "/api/media/{id}/stream", s.requireSession(s.handleStream)) + s.handleBoth(http.MethodGet, "/api/media/{id}/download", s.requireSession(s.handleDownload)) + s.handleBoth(http.MethodGet, "/api/media/{id}/thumbnail", s.requireSession(s.handleThumbnail)) + s.handleBoth(http.MethodPost, "/api/media/{id}/thumbnail", s.requireSession(s.handleRegenThumbnail)) + s.handleBoth(http.MethodPost, "/api/media/{id}/favorite", s.requireSession(s.handleFavorite)) + s.handleBoth(http.MethodGet, "/api/tags", s.requireSession(s.handleListTags)) + s.handleBoth(http.MethodPost, "/api/media/{id}/tags", s.requireSession(s.handleAddTag)) + s.handleBoth(http.MethodDelete, "/api/media/{id}/tags/{tag}", s.requireSession(s.handleRemoveTag)) + s.handleBoth(http.MethodDelete, "/api/media/{id}", s.requireSession(s.handleSoftDelete)) + s.handleBoth(http.MethodPost, "/api/media/{id}/restore", s.requireSession(s.handleRestore)) + s.handleBoth(http.MethodPost, "/api/media/{id}/shares", s.requireSession(s.handleCreateShare)) + s.handleBoth(http.MethodGet, "/api/media/{id}/shares", s.requireSession(s.handleListShares)) } // routesNotes wires the notes API routes. func (s *Server) routesNotes() { - s.mux.Handle("GET /api/media/{id}/notes", s.requireSession(s.handleGetNote)) - s.mux.Handle("POST /api/media/{id}/notes", s.requireSession(s.handleUpsertNote)) - s.mux.Handle("DELETE /api/media/{id}/notes", s.requireSession(s.handleDeleteNote)) + s.handleBoth(http.MethodGet, "/api/media/{id}/notes", s.requireSession(s.handleGetNote)) + s.handleBoth(http.MethodPost, "/api/media/{id}/notes", s.requireSession(s.handleUpsertNote)) + s.handleBoth(http.MethodDelete, "/api/media/{id}/notes", s.requireSession(s.handleDeleteNote)) } // routesProgress wires the progress API routes. func (s *Server) routesProgress() { - s.mux.Handle("POST /api/progress", s.requireSession(s.handleProgress)) - s.mux.Handle("POST /api/progress/status", s.requireSession(s.handleProgressStatus)) - s.mux.Handle("GET /api/in-progress", s.requireSession(s.handleInProgress)) + s.handleBoth(http.MethodPost, "/api/progress", s.requireSession(s.handleProgress)) + s.handleBoth(http.MethodPost, "/api/progress/status", s.requireSession(s.handleProgressStatus)) + s.handleBoth(http.MethodGet, "/api/in-progress", s.requireSession(s.handleInProgress)) } // routesShares wires the share-management API routes. func (s *Server) routesShares() { - s.mux.Handle("DELETE /api/shares/{token}", s.requireSession(s.handleRevokeShare)) - s.mux.Handle("GET /api/shares", s.requireSession(s.handleMyShares)) + s.handleBoth(http.MethodDelete, "/api/shares/{token}", s.requireSession(s.handleRevokeShare)) + s.handleBoth(http.MethodGet, "/api/shares", s.requireSession(s.handleMyShares)) } // routesAdmin wires the admin-only API routes. func (s *Server) routesAdmin() { - s.mux.Handle("GET /api/admin/trash", s.requireAdmin(s.handleListTrash)) - s.mux.Handle("POST /api/admin/rescan", s.requireAdmin(s.handleRescan)) - s.mux.Handle("GET /api/admin/scan-progress", s.requireAdmin(s.handleScanProgress)) - s.mux.Handle("GET /api/admin/users", s.requireAdmin(s.handleListUsers)) - s.mux.Handle("POST /api/admin/users", s.requireAdmin(s.handleCreateUser)) - s.mux.Handle("DELETE /api/admin/users/{id}", s.requireAdmin(s.handleDeleteUser)) - s.mux.Handle("GET /api/admin/permissions", s.requireAdmin(s.handleListPermissions)) - s.mux.Handle("POST /api/admin/permissions", s.requireAdmin(s.handleGrantPermission)) - s.mux.Handle("DELETE /api/admin/permissions", s.requireAdmin(s.handleRevokePermission)) + s.handleBoth(http.MethodGet, "/api/admin/trash", s.requireAdmin(s.handleListTrash)) + s.handleBoth(http.MethodPost, "/api/admin/rescan", s.requireAdmin(s.handleRescan)) + s.handleBoth(http.MethodGet, "/api/admin/scan-progress", s.requireAdmin(s.handleScanProgress)) + s.handleBoth(http.MethodGet, "/api/admin/users", s.requireAdmin(s.handleListUsers)) + s.handleBoth(http.MethodPost, "/api/admin/users", s.requireAdmin(s.handleCreateUser)) + s.handleBoth(http.MethodDelete, "/api/admin/users/{id}", s.requireAdmin(s.handleDeleteUser)) + s.handleBoth(http.MethodGet, "/api/admin/permissions", s.requireAdmin(s.handleListPermissions)) + s.handleBoth(http.MethodPost, "/api/admin/permissions", s.requireAdmin(s.handleGrantPermission)) + s.handleBoth(http.MethodDelete, "/api/admin/permissions", s.requireAdmin(s.handleRevokePermission)) } func (s *Server) routes() { @@ -256,11 +272,11 @@ func (s *Server) routes() { // routesPodcast wires the podcast API routes. func (s *Server) routesPodcast() { - s.mux.Handle("GET /api/podcasts", s.requireSession(s.handleListPodcasts)) - s.mux.Handle("POST /api/podcasts", s.requireAdmin(s.handleSubscribePodcast)) - s.mux.Handle("GET /api/podcasts/{id}/episodes", s.requireSession(s.handleListEpisodes)) - s.mux.Handle("POST /api/podcasts/episodes/{episode_id}/download", s.requireSession(s.handleDownloadEpisode)) - s.mux.Handle("POST /api/podcasts/episodes/{episode_id}/complete", s.requireSession(s.handleToggleComplete)) + s.handleBoth(http.MethodGet, "/api/podcasts", s.requireSession(s.handleListPodcasts)) + s.handleBoth(http.MethodPost, "/api/podcasts", s.requireAdmin(s.handleSubscribePodcast)) + s.handleBoth(http.MethodGet, "/api/podcasts/{id}/episodes", s.requireSession(s.handleListEpisodes)) + s.handleBoth(http.MethodPost, "/api/podcasts/episodes/{episode_id}/download", s.requireSession(s.handleDownloadEpisode)) + s.handleBoth(http.MethodPost, "/api/podcasts/episodes/{episode_id}/complete", s.requireSession(s.handleToggleComplete)) } func (s *Server) pingStore(ctx context.Context) error { -- cgit v1.2.3