refactor(plugins): inject PluginManager into native API

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-28 12:00:29 -05:00
parent 064e73f958
commit 3605d5bf08
10 changed files with 143 additions and 81 deletions

View File

@ -71,7 +71,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
watcher := scanner.GetWatcher(dataStore, modelScanner)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
maintenance := core.NewMaintenance(dataStore)
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance, manager)
return router
}
@ -194,7 +194,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -44,6 +44,7 @@ var allProviders = wire.NewSet(
plugins.GetManager,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
)

View File

@ -117,13 +117,6 @@ func GetManager() *Manager {
})
}
// IsConfigured returns true if the manager has been configured with a DataStore.
// This is useful for API handlers to know if they should use the manager or fall back
// to direct DB operations (e.g., in test environments).
func (m *Manager) IsConfigured() bool {
return m.ds != nil
}
// adminContext returns a context with admin privileges for DB operations.
func adminContext(ctx context.Context) context.Context {
return request.WithUser(ctx, model.User{IsAdmin: true})

View File

@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users

View File

@ -30,7 +30,7 @@ var _ = Describe("Library API", func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users

View File

@ -20,18 +20,27 @@ import (
"github.com/navidrome/navidrome/server"
)
type Router struct {
http.Handler
ds model.DataStore
share core.Share
playlists core.Playlists
insights metrics.Insights
libs core.Library
maintenance core.Maintenance
// PluginManager defines the interface for plugin management operations.
// This interface is used by the API handlers to enable/disable plugins and update configuration.
type PluginManager interface {
EnablePlugin(ctx context.Context, id string) error
DisablePlugin(ctx context.Context, id string) error
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
}
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
type Router struct {
http.Handler
ds model.DataStore
share core.Share
playlists core.Playlists
insights metrics.Insights
libs core.Library
maintenance core.Maintenance
pluginManager PluginManager
}
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance, pluginManager PluginManager) *Router {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance, pluginManager: pluginManager}
r.Handler = r.routes()
return r
}

View File

@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
mfRepo.SetData(testSongs)
// Create the native API router and wrap it with the JWTVerifier middleware
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})

View File

@ -11,7 +11,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/server"
)
@ -52,7 +51,6 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
ctx := r.Context()
repo := api.ds.Plugin(ctx)
manager := plugins.GetManager()
// Get existing plugin to verify it exists
plugin, err := repo.Get(id)
@ -78,64 +76,39 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
return
}
// If manager is configured, use it to properly load/unload plugins
// Otherwise, fall back to direct DB operations (e.g., in tests)
if manager.IsConfigured() {
// Handle config update first (if provided)
if req.Config != nil {
// Validate JSON if not empty
if *req.Config != "" && !isValidJSON(*req.Config) {
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
return
}
if err := manager.UpdatePluginConfig(ctx, id, *req.Config); err != nil {
log.Error(ctx, "Error updating plugin config", "id", id, err)
http.Error(w, "Error updating plugin configuration: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Handle enable/disable
if req.Enabled != nil {
if *req.Enabled {
if err := manager.EnablePlugin(ctx, id); err != nil {
log.Error(ctx, "Error enabling plugin", "id", id, err)
// Refresh plugin from DB to get the error
plugin, _ = repo.Get(id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
_ = json.NewEncoder(w).Encode(plugin)
return
}
} else {
if err := manager.DisablePlugin(ctx, id); err != nil {
log.Error(ctx, "Error disabling plugin", "id", id, err)
http.Error(w, "Error disabling plugin: "+err.Error(), http.StatusInternalServerError)
return
}
}
}
} else {
// Fallback: direct DB operations (for tests or when manager is not started)
if req.Config != nil {
if *req.Config != "" && !isValidJSON(*req.Config) {
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
return
}
plugin.Config = *req.Config
}
if req.Enabled != nil {
plugin.Enabled = *req.Enabled
}
if err := repo.Put(plugin); err != nil {
if errors.Is(err, rest.ErrPermissionDenied) {
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
return
}
log.Error(ctx, "Error updating plugin", "id", id, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
// Handle config update first (if provided)
if req.Config != nil {
// Validate JSON if not empty
if *req.Config != "" && !isValidJSON(*req.Config) {
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
return
}
if err := api.pluginManager.UpdatePluginConfig(ctx, id, *req.Config); err != nil {
log.Error(ctx, "Error updating plugin config", "id", id, err)
http.Error(w, "Error updating plugin configuration: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Handle enable/disable
if req.Enabled != nil {
if *req.Enabled {
if err := api.pluginManager.EnablePlugin(ctx, id); err != nil {
log.Error(ctx, "Error enabling plugin", "id", id, err)
// Refresh plugin from DB to get the error
plugin, _ = repo.Get(id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
_ = json.NewEncoder(w).Encode(plugin)
return
}
} else {
if err := api.pluginManager.DisablePlugin(ctx, id); err != nil {
log.Error(ctx, "Error disabling plugin", "id", id, err)
http.Error(w, "Error disabling plugin: "+err.Error(), http.StatusInternalServerError)
return
}
}
}
// Refresh and return updated plugin

View File

@ -2,6 +2,7 @@ package nativeapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@ -21,6 +22,7 @@ import (
var _ = Describe("Plugin API", func() {
var ds *tests.MockDataStore
var mockManager *tests.MockPluginManager
var router http.Handler
var adminUser, regularUser model.User
var testPlugin1, testPlugin2 model.Plugin
@ -29,8 +31,9 @@ var _ = Describe("Plugin API", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
ds = &tests.MockDataStore{}
mockManager = &tests.MockPluginManager{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil, mockManager)
router = server.JWTVerifier(nativeRouter)
// Create test users
@ -153,6 +156,14 @@ var _ = Describe("Plugin API", func() {
Describe("PUT /api/plugin/{id}", func() {
It("updates plugin enabled state", func() {
// Configure mock to update the repo when EnablePlugin is called
mockManager.EnablePluginFn = func(ctx context.Context, id string) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Enabled = true
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"enabled":true}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
@ -167,9 +178,18 @@ var _ = Describe("Plugin API", func() {
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.Enabled).To(BeTrue())
Expect(mockManager.EnablePluginCalls).To(ContainElement("test-plugin-1"))
})
It("updates plugin config with valid JSON", func() {
// Configure mock to update the repo when UpdatePluginConfig is called
mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Config = configJSON
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"config":"{\"key\":\"value\"}"}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
@ -184,6 +204,8 @@ var _ = Describe("Plugin API", func() {
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.Config).To(Equal(`{"key":"value"}`))
Expect(mockManager.UpdatePluginConfigCalls).To(HaveLen(1))
Expect(mockManager.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(`{"key":"value"}`))
})
It("rejects invalid JSON in config field", func() {
@ -200,6 +222,14 @@ var _ = Describe("Plugin API", func() {
})
It("allows empty config", func() {
// Configure mock to update the repo when UpdatePluginConfig is called
mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Config = configJSON
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"config":""}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)

View File

@ -0,0 +1,56 @@
package tests
import (
"context"
)
// MockPluginManager is a mock implementation of plugins.PluginManager for testing.
// It implements EnablePlugin, DisablePlugin, and UpdatePluginConfig methods.
type MockPluginManager struct {
// EnablePluginFn is called when EnablePlugin is invoked. If nil, returns EnableError.
EnablePluginFn func(ctx context.Context, id string) error
// DisablePluginFn is called when DisablePlugin is invoked. If nil, returns DisableError.
DisablePluginFn func(ctx context.Context, id string) error
// UpdatePluginConfigFn is called when UpdatePluginConfig is invoked. If nil, returns ConfigError.
UpdatePluginConfigFn func(ctx context.Context, id, configJSON string) error
// Default errors to return when Fn callbacks are not set
EnableError error
DisableError error
ConfigError error
// Track calls for assertions
EnablePluginCalls []string
DisablePluginCalls []string
UpdatePluginConfigCalls []struct {
ID string
ConfigJSON string
}
}
func (m *MockPluginManager) EnablePlugin(ctx context.Context, id string) error {
m.EnablePluginCalls = append(m.EnablePluginCalls, id)
if m.EnablePluginFn != nil {
return m.EnablePluginFn(ctx, id)
}
return m.EnableError
}
func (m *MockPluginManager) DisablePlugin(ctx context.Context, id string) error {
m.DisablePluginCalls = append(m.DisablePluginCalls, id)
if m.DisablePluginFn != nil {
return m.DisablePluginFn(ctx, id)
}
return m.DisableError
}
func (m *MockPluginManager) UpdatePluginConfig(ctx context.Context, id, configJSON string) error {
m.UpdatePluginConfigCalls = append(m.UpdatePluginConfigCalls, struct {
ID string
ConfigJSON string
}{ID: id, ConfigJSON: configJSON})
if m.UpdatePluginConfigFn != nil {
return m.UpdatePluginConfigFn(ctx, id, configJSON)
}
return m.ConfigError
}