refactor(api): group API v1 handlers into one file per OpenAPI tag

Handlers live in <tag>_handlers.go, so the package grows by tag rather than
by endpoint, and shared convention helpers keep plain names without
clashing with tag files.

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-09-28 22:08:01 -04:00
commit cbc22b09cb
7 changed files with 79 additions and 97 deletions

View file

@ -3,7 +3,9 @@ package apiv1
import (
"cmp"
"context"
"errors"
"github.com/navidrome/navidrome/core/apiauth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
@ -11,6 +13,45 @@ import (
const defaultPageSize = 100
// Login relies on model.ErrInvalidAuth mapping to a detail-less 401, so unknown user and wrong password look the same.
func (rt *Router) Login(ctx context.Context, req LoginRequestObject) (LoginResponseObject, error) {
b := *req.Body
issued, err := rt.auth.Login(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes))
if err != nil {
return nil, err
}
return Login200JSONResponse{Body: toGrantCreated(issued)}, nil
}
func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) {
b := *req.Body
issued, err := rt.auth.Setup(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes))
if err != nil {
return nil, err
}
return SetupFirstAdmin201JSONResponse{Body: toGrantCreated(issued)}, nil
}
func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) {
p, err := principalFrom(ctx)
if err != nil {
return nil, err
}
b := *req.Body
revoke := true
if b.RevokeOtherGrants != nil {
revoke = *b.RevokeOtherGrants
}
err = rt.auth.ChangePassword(ctx, p, b.CurrentPassword, b.NewPassword, revoke)
if errors.Is(err, apiauth.ErrCurrentPasswordMismatch) {
return nil, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"})
}
if err != nil {
return nil, err
}
return ChangePassword204Response{}, nil
}
func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) {
p, err := principalFrom(ctx)
if err != nil {

View file

@ -1,10 +0,0 @@
package apiv1
import "context"
func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) {
return GetCapabilities200JSONResponse{
Core: &CoreCapability{Version: 1},
Password: &PasswordCapability{Version: 1},
}, nil
}

View file

@ -1,40 +0,0 @@
package apiv1
import (
"context"
"net/http"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GET /capabilities", func() {
var ctx context.Context
var api testClient
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
resetDB()
api = testClient{ctx: ctx, router: New(realDS)}
})
It("needs a grant", func() {
w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("lists core and password for any valid grant, even one with no scopes", func() {
api.setup()
gc := api.login([]string{})
Expect(gc.Grant.Scopes).To(BeEmpty())
w := api.call(http.MethodGet, "/api/v1/capabilities", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK))
var caps Capabilities
decodeJSON(w, &caps)
Expect(caps.Core.Version).To(Equal(1))
Expect(caps.Password.Version).To(Equal(1))
})
})

View file

@ -1,47 +0,0 @@
package apiv1
import (
"context"
"errors"
"github.com/navidrome/navidrome/core/apiauth"
)
// Login relies on model.ErrInvalidAuth mapping to a detail-less 401, so unknown user and wrong password look the same.
func (rt *Router) Login(ctx context.Context, req LoginRequestObject) (LoginResponseObject, error) {
b := *req.Body
issued, err := rt.auth.Login(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes))
if err != nil {
return nil, err
}
return Login200JSONResponse{Body: toGrantCreated(issued)}, nil
}
func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) {
b := *req.Body
issued, err := rt.auth.Setup(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes))
if err != nil {
return nil, err
}
return SetupFirstAdmin201JSONResponse{Body: toGrantCreated(issued)}, nil
}
func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) {
p, err := principalFrom(ctx)
if err != nil {
return nil, err
}
b := *req.Body
revoke := true
if b.RevokeOtherGrants != nil {
revoke = *b.RevokeOtherGrants
}
err = rt.auth.ChangePassword(ctx, p, b.CurrentPassword, b.NewPassword, revoke)
if errors.Is(err, apiauth.ErrCurrentPasswordMismatch) {
return nil, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"})
}
if err != nil {
return nil, err
}
return ChangePassword204Response{}, nil
}

View file

@ -21,3 +21,10 @@ func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObjec
LoginMethods: LoginMethods{Password: &PasswordLoginMethod{}},
}, nil
}
func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) {
return GetCapabilities200JSONResponse{
Core: &CoreCapability{Version: 1},
Password: &PasswordCapability{Version: 1},
}, nil
}

View file

@ -8,6 +8,7 @@ import (
"net/http/httptest"
"github.com/navidrome/navidrome/api"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@ -60,3 +61,33 @@ var _ = Describe("GET /server", func() {
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInternal))
})
})
var _ = Describe("GET /capabilities", func() {
var ctx context.Context
var api testClient
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
resetDB()
api = testClient{ctx: ctx, router: New(realDS)}
})
It("needs a grant", func() {
w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("lists core and password for any valid grant, even one with no scopes", func() {
api.setup()
gc := api.login([]string{})
Expect(gc.Grant.Scopes).To(BeEmpty())
w := api.call(http.MethodGet, "/api/v1/capabilities", gc.Secret, nil)
Expect(w.Code).To(Equal(http.StatusOK))
var caps Capabilities
decodeJSON(w, &caps)
Expect(caps.Core.Version).To(Equal(1))
Expect(caps.Password.Version).To(Equal(1))
})
})