Generate the OpenAPI spec with Huma instead of swag
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped

The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
This commit is contained in:
Sansan committed 2026-10-06 16:31:06 +02:00
1 parent 0ec2425f78
commit 973ebf224b
37 files changed
+1245 -5967

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@ func (c *CharacterService) List() ([]string, error) {
if err != nil {
return nil, err
}
var characters []string
characters := []string{}
for _, entry := range entries {
if !entry.IsDir() && isImage(entry.Name()) {
characters = append(characters, entry.Name())
+1 -1
View File
@@ -198,7 +198,7 @@ func (m *MusicService) SongInfo(ctx context.Context) SongInfo {
// PlayedSongs returns all songs in the queue.
func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo {
var songList []SongInfo
songList := []SongInfo{}
for i, song := range m.queue {
soundtrack := m.soundtrackFor(ctx, song)
songList = append(songList, SongInfo{
+3 -3
View File
@@ -66,7 +66,7 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs,
if err != nil {
return nil, err
}
var result []SoundtrackWithSongs
result := []SoundtrackWithSongs{}
for _, r := range rows {
row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
result = append(result, SoundtrackWithSongs{
@@ -89,7 +89,7 @@ func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) {
if err != nil {
return nil, err
}
var result []SongInfoForStats
result := []SongInfoForStats{}
for _, r := range rows {
result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r)))
}
@@ -132,7 +132,7 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
if err != nil {
return nil, err
}
var result []SoundtrackWithSongs
result := []SoundtrackWithSongs{}
for _, row := range rows {
result = append(result, SoundtrackWithSongs{
SoundtrackID: row.SoundtrackID,
+14 -2
View File
@@ -136,11 +136,23 @@ func (s *SyncService) Progress() ProgressResponse {
}
}
// Result returns the result of the last finished sync.
// Result returns the result of the last finished sync. Lists are empty, not nil, before the first sync.
func (s *SyncService) Result() SyncResponse {
s.mu.Lock()
defer s.mu.Unlock()
return s.result
return s.result.withEmptyLists()
}
func (r SyncResponse) withEmptyLists() SyncResponse {
for _, list := range []*[]string{&r.SoundtracksAdded, &r.SoundtracksReAdded, &r.SoundtracksChangedContent, &r.SoundtracksRemoved, &r.CatchedErrors} {
if *list == nil {
*list = []string{}
}
}
if r.SoundtracksChangedTitle == nil {
r.SoundtracksChangedTitle = map[string]string{}
}
return r
}
// ResetDB deletes all songs and soundtracks.
+8 -1
View File
@@ -371,7 +371,14 @@ func TestSyncStartOnlyOnce(t *testing.T) {
func TestSyncProgressBeforeFirstSync(t *testing.T) {
s := NewSyncService(fakedb.New(), t.TempDir(), nil)
assert.Equal(t, "0", s.Progress().Progress)
assert.Equal(t, SyncResponse{}, s.Result())
assert.Equal(t, SyncResponse{
SoundtracksAdded: []string{},
SoundtracksReAdded: []string{},
SoundtracksChangedTitle: map[string]string{},
SoundtracksChangedContent: []string{},
SoundtracksRemoved: []string{},
CatchedErrors: []string{},
}, s.Result(), "lists are empty, not null, so the JSON matches the API schema")
}
func TestSyncCallsOnCompleteBeforeFinishing(t *testing.T) {
+165
View File
@@ -0,0 +1,165 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
const (
apiPrefix = "/api/v1"
bearerAuthName = "bearer"
)
var bearerAuth = []map[string][]string{{bearerAuthName: {}}}
// APIError is the body of every error response.
type APIError struct {
status int
Message string `json:"error" example:"Not Found" doc:"What went wrong"`
}
func (e *APIError) Error() string { return e.Message }
func (e *APIError) GetStatus() int { return e.status }
func init() {
// The API always returns [] for empty lists, never null.
huma.DefaultArrayNullable = false
// Huma creates all its errors (validation, 404, ...) through NewError.
huma.NewError = func(status int, msg string, errs ...error) huma.StatusError {
var details []string
for _, err := range errs {
if err != nil {
details = append(details, err.Error())
}
}
if len(details) > 0 {
msg += ": " + strings.Join(details, "; ")
}
return &APIError{status: status, Message: msg}
}
}
// apis holds the documented /api/v1 API and the hidden, deprecated legacy routes.
type apis struct {
v1 huma.API
legacy huma.API
}
func newAPIs(e *echo.Echo) apis {
config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version)
// No $schema fields or Link headers in responses.
config.CreateHooks = nil
config.DocsRenderer = huma.DocsRendererScalar
config.Info.Description = "API for the MusicServer application.\n\n" +
"Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer <token>`.\n\n" +
"The same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token " +
"and are not documented here. They will be removed."
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"},
}
legacyConfig := huma.DefaultConfig("MusicServer legacy API", backend.GetLatestVersion().Version)
legacyConfig.CreateHooks = nil
legacyConfig.OpenAPIPath = ""
legacyConfig.DocsPath = ""
legacyConfig.SchemasPath = ""
return apis{v1: humaecho.New(e, config), legacy: humaecho.New(e, legacyConfig)}
}
// route describes an endpoint registered on /api/v1 and, optionally, as a legacy route.
type route struct {
op huma.Operation
// public endpoints need no token.
public bool
// legacyPath is the deprecated path without /api/v1 (empty: no legacy route).
legacyPath string
// locked endpoints answer 423 while a sync is running.
locked bool
}
// register adds the route's operation to the v1 API and, if it has one, its hidden legacy route.
func register[I, O any](s *Server, a apis, r route, handler func(context.Context, *I) (*O, error)) {
op := r.op
op.Path = apiPrefix + r.op.Path
if !r.public {
op.Security = bearerAuth
op.Errors = append(op.Errors, http.StatusUnauthorized)
}
if r.locked {
op.Middlewares = append(op.Middlewares, s.syncLock(a.v1))
op.Errors = append(op.Errors, http.StatusLocked)
}
huma.Register(a.v1, op, handler)
if r.legacyPath == "" {
return
}
legacy := r.op
legacy.OperationID = "legacy-" + r.op.OperationID
legacy.Path = r.legacyPath
legacy.Hidden = true
legacy.Deprecated = true
if r.locked {
legacy.Middlewares = append(legacy.Middlewares, s.syncLock(a.legacy))
}
huma.Register(a.legacy, legacy, handler)
}
// syncLock answers 423 Locked while a sync is running.
func (s *Server) syncLock(api huma.API) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
if s.sync.IsSyncing() {
logging.GetLogger().Info("Syncing is in progress - request blocked")
_ = huma.WriteErr(api, ctx, http.StatusLocked, "Syncing is in progress")
return
}
next(ctx)
}
}
// apiError maps backend errors to API errors.
func apiError(err error) error {
switch {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
return huma.Error404NotFound(err.Error())
case errors.Is(err, backend.ErrInvalidName):
return huma.Error400BadRequest(err.Error())
case errors.Is(err, backend.ErrUpstream):
return huma.Error502BadGateway(err.Error())
default:
logging.GetLogger().Error("Request failed", zap.Error(err))
return huma.Error500InternalServerError("Internal Server Error")
}
}
// echoContext returns the Echo context behind a Huma context.
func echoContext(ctx huma.Context) *echo.Context {
return humaecho.Unwrap(ctx)
}
// noBody is the output of operations that only return a status.
type noBody struct{}
// jsonBody wraps a JSON response body.
type jsonBody[T any] struct {
Body T
}
func ok[T any](body T, err error) (*jsonBody[T], error) {
if err != nil {
return nil, apiError(err)
}
return &jsonBody[T]{Body: body}, nil
}
-51
View File
@@ -1,51 +0,0 @@
package server
import (
"net/http"
"music-server/internal/backend"
"github.com/labstack/echo/v5"
)
type CharacterHandler struct {
characters *backend.CharacterService
}
func NewCharacterHandler(characters *backend.CharacterService) *CharacterHandler {
return &CharacterHandler{characters: characters}
}
// GetCharacterList godoc
// @Summary Get list of characters
// @Description Returns a list of all available characters
// @Tags characters
// @Produce json
// @Success 200 {array} string
// @Router /characters [get]
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
characters, err := c.characters.List()
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, characters)
}
// GetCharacter godoc
// @Summary Get character image
// @Description Returns the image for a specific character
// @Tags characters
// @Produce image/png
// @Produce image/jpeg
// @Param name query string true "Character file name"
// @Success 200 {file} file
// @Failure 400 {string} string "invalid name"
// @Failure 404 {string} string "not found"
// @Router /character [get]
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
f, err := c.characters.Open(ctx.QueryParam("name"))
if err != nil {
return songError(err)
}
return serveFile(ctx, f, "")
}
-81
View File
@@ -1,81 +0,0 @@
package server
import (
"net/http"
"music-server/internal/backend"
"github.com/labstack/echo/v5"
)
type DownloadHandler struct {
releases *backend.ReleaseService
}
func NewDownloadHandler(releases *backend.ReleaseService) *DownloadHandler {
return &DownloadHandler{releases: releases}
}
// CheckLatest godoc
// @Summary Check for latest version
// @Description Returns the latest version of the client application
// @Tags download
// @Produce json
// @Success 200 {string} string
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download [get]
func (d *DownloadHandler) CheckLatest(ctx *echo.Context) error {
latest, err := d.releases.LatestVersion(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, latest)
}
// ListAssetsOfLatest godoc
// @Summary List assets of latest version
// @Description Lists all assets available for the latest version
// @Tags download
// @Produce json
// @Success 200 {array} string
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download/list [get]
func (d *DownloadHandler) ListAssetsOfLatest(ctx *echo.Context) error {
assets, err := d.releases.AssetNames(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, assets)
}
// DownloadLatestWindows godoc
// @Summary Download latest Windows version
// @Description Redirects to download the latest Windows version
// @Tags download
// @Success 302 {string} string
// @Failure 404 {string} string "not found"
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download/windows [get]
func (d *DownloadHandler) DownloadLatestWindows(ctx *echo.Context) error {
return d.redirectToAsset(ctx, ".exe")
}
// DownloadLatestLinux godoc
// @Summary Download latest Linux version
// @Description Redirects to download the latest Linux version
// @Tags download
// @Success 302 {string} string
// @Failure 404 {string} string "not found"
// @Failure 502 {string} string "upstream service unavailable"
// @Router /download/linux [get]
func (d *DownloadHandler) DownloadLatestLinux(ctx *echo.Context) error {
return d.redirectToAsset(ctx, ".x86_64")
}
func (d *DownloadHandler) redirectToAsset(ctx *echo.Context, suffix string) error {
url, err := d.releases.AssetURL(ctx.Request().Context(), suffix)
if err != nil {
return songError(err)
}
return ctx.Redirect(http.StatusFound, url)
}
+2 -7
View File
@@ -10,12 +10,7 @@ import (
"go.uber.org/zap"
)
// ErrorResponse is the body of every error response.
type ErrorResponse struct {
Error string `json:"error" example:"Not Found"`
}
// httpErrorHandler writes every error as an ErrorResponse.
// httpErrorHandler writes errors from Echo (unknown routes, panics, ...) as an APIError, like Huma does.
func httpErrorHandler(c *echo.Context, err error) {
if r, _ := echo.UnwrapResponse(c.Response()); r != nil && r.Committed {
return
@@ -43,5 +38,5 @@ func httpErrorHandler(c *echo.Context, err error) {
_ = c.NoContent(code)
return
}
_ = c.JSON(code, ErrorResponse{Error: message})
_ = c.JSON(code, &APIError{Message: message})
}
+39 -40
View File
@@ -1,61 +1,60 @@
package server
import (
"errors"
"net/http"
"os"
"path/filepath"
"time"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
"github.com/danielgtaylor/huma/v2"
)
// serveFile sends a file with support for Range requests and caching headers.
// contentType may be empty to detect it from the file extension.
func serveFile(c *echo.Context, f *os.File, contentType string) error {
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
// fileResponses documents an endpoint that returns a file with Range support.
func fileResponses(description string, contentTypes ...string) map[string]*huma.Response {
content := map[string]*huma.MediaType{}
for _, contentType := range contentTypes {
content[contentType] = &huma.MediaType{Schema: &huma.Schema{Type: "string", Format: "binary"}}
}
if info.IsDir() {
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
return map[string]*huma.Response{
"200": {Description: description, Content: content},
"206": {Description: "Part of the file, for a Range request", Content: content},
}
if contentType != "" {
c.Response().Header().Set(echo.HeaderContentType, contentType)
}
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
return nil
}
// serveSong streams a song. The write deadline is lifted because songs can take long to send to slow clients.
func serveSong(c *echo.Context, path string, err error) error {
if err != nil {
return songError(err)
}
// streamFile opens path and returns a response that sends it with Range support.
// contentType may be empty to detect it from the file extension. Audio has no write
// deadline because songs can take long to send to slow clients.
func streamFile(path, contentType string, audio bool) (*huma.StreamResponse, error) {
f, err := os.Open(path)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
return nil, huma.Error404NotFound("Not Found")
}
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
return serveFile(c, f, "audio/mpeg")
return streamOpenFile(f, contentType, audio)
}
// songError maps backend errors to HTTP errors.
func songError(err error) error {
switch {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
return echo.NewHTTPError(http.StatusNotFound, err.Error())
case errors.Is(err, backend.ErrInvalidName):
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
case errors.Is(err, backend.ErrUpstream):
return echo.NewHTTPError(http.StatusBadGateway, err.Error())
default:
logging.GetLogger().Error("Request failed", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error")
func streamOpenFile(f *os.File, contentType string, audio bool) (*huma.StreamResponse, error) {
info, err := f.Stat()
if err != nil || info.IsDir() {
f.Close()
return nil, huma.Error404NotFound("Not Found")
}
return &huma.StreamResponse{Body: func(ctx huma.Context) {
defer f.Close()
c := echoContext(ctx)
if contentType != "" {
c.Response().Header().Set("Content-Type", contentType)
}
if audio {
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
}
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
}}, nil
}
// streamSong streams the song at path, or returns err mapped to an API error.
func streamSong(path string, err error) (*huma.StreamResponse, error) {
if err != nil {
return nil, apiError(err)
}
return streamFile(path, "audio/mpeg", true)
}
+12 -6
View File
@@ -40,7 +40,7 @@ func TestMusicRandomAndQueue(t *testing.T) {
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
}
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code)
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodPut, "/music/played?song=x").Code)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/music/played?song=x").Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/reset").Code)
}
@@ -60,12 +60,13 @@ func TestMusicEmptyStates(t *testing.T) {
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/music/rand", "/music/rand/low", "/music/rand/classic"} {
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
}
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music?song=abc").Code)
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music").Code)
// Invalid or missing parameters fail validation.
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music?song=abc").Code)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music").Code)
rec := e.do(http.MethodGet, "/music/list")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "null\n", rec.Body.String())
assert.JSONEq(t, "[]", rec.Body.String())
}
func TestMusicSoundCheck(t *testing.T) {
@@ -118,7 +119,8 @@ func TestCharacterEndpoints(t *testing.T) {
func TestCharacterPathTraversal(t *testing.T) {
e := newTestEnv(t)
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd", ""} {
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/character?name=").Code, "name is required")
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} {
rec := e.do(http.MethodGet, "/character?name="+name)
assert.Equal(t, http.StatusBadRequest, rec.Code, name)
assert.NotContains(t, rec.Body.String(), "secret", name)
@@ -262,6 +264,10 @@ func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatist
return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err
}
func statsService() *backend.StatisticsService {
return backend.NewStatisticsService(statsStore{})
}
func TestStatisticsEndpoints(t *testing.T) {
e := newTestEnv(t)
e.server.stats = backend.NewStatisticsService(statsStore{})
@@ -279,7 +285,7 @@ func TestStatisticsEndpoints(t *testing.T) {
assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped")
for _, limit := range []string{"0", "-1", "abc"} {
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit)
}
var summary backend.StatisticsSummary
-33
View File
@@ -1,33 +0,0 @@
package server
import (
"context"
"net/http"
"github.com/labstack/echo/v5"
)
// HealthChecker reports the health of a dependency.
type HealthChecker interface {
Health(ctx context.Context) map[string]string
}
type HealthHandler struct {
db HealthChecker
}
func NewHealthHandler(db HealthChecker) *HealthHandler {
return &HealthHandler{db: db}
}
// HealthCheck godoc
//
// @Summary Check server health
// @Description Returns the health status of the server
// @Tags health
// @Produce json
// @Success 200 {object} map[string]string
// @Router /health [get]
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, h.db.Health(ctx.Request().Context()))
}
+41 -93
View File
@@ -1,111 +1,59 @@
package server
import (
"context"
"net/http"
"music-server/internal/backend"
"github.com/danielgtaylor/huma/v2"
"github.com/google/uuid"
"github.com/labstack/echo/v5"
)
type LibraryHandler struct {
library *backend.LibraryService
type uuidInput struct {
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack or song"`
}
func NewLibraryHandler(library *backend.LibraryService) *LibraryHandler {
return &LibraryHandler{library: library}
func (in *uuidInput) id() uuid.UUID {
// The format is validated by Huma before the handler runs.
return uuid.MustParse(in.UUID)
}
// uuidParam parses the :uuid path parameter.
func uuidParam(ctx *echo.Context) (uuid.UUID, error) {
id, err := uuid.Parse(ctx.Param("uuid"))
if err != nil {
return uuid.Nil, echo.NewHTTPError(http.StatusBadRequest, "invalid uuid")
func (s *Server) registerLibrary(a apis) {
l := s.library
op := func(id, path, summary, description string, errs ...int) huma.Operation {
return huma.Operation{
OperationID: id,
Method: http.MethodGet,
Path: path,
Summary: summary,
Description: description,
Tags: []string{"library"},
Errors: errs,
}
}
return id, nil
}
// GetSoundtracks godoc
//
// @Summary List soundtracks
// @Description Returns all soundtracks with their UUIDs, sorted by name
// @Tags library
// @Produce json
// @Success 200 {array} backend.Soundtrack
// @Router /api/v1/soundtracks [get]
func (l *LibraryHandler) GetSoundtracks(ctx *echo.Context) error {
soundtracks, err := l.library.Soundtracks(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, soundtracks)
}
register(s, a, route{op: op("list-soundtracks", "/soundtracks", "List soundtracks",
"Returns all soundtracks, sorted by name.")},
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.Soundtrack], error) {
return ok(l.Soundtracks(ctx))
})
register(s, a, route{op: op("get-soundtrack", "/soundtracks/{uuid}", "Get soundtrack",
"Returns a soundtrack with its songs, sorted by name.", http.StatusNotFound)},
func(ctx context.Context, in *uuidInput) (*jsonBody[backend.SoundtrackWithSongList], error) {
return ok(l.Soundtrack(ctx, in.id()))
})
register(s, a, route{op: op("get-song", "/songs/{uuid}", "Get song",
"Returns information about a song.", http.StatusNotFound)},
func(ctx context.Context, in *uuidInput) (*jsonBody[backend.Song], error) {
return ok(l.Song(ctx, in.id()))
})
// GetSoundtrack godoc
//
// @Summary Get soundtrack
// @Description Returns a soundtrack with its songs
// @Tags library
// @Produce json
// @Param uuid path string true "Soundtrack UUID"
// @Success 200 {object} backend.SoundtrackWithSongList
// @Failure 400 {string} string "invalid uuid"
// @Failure 404 {string} string "not found"
// @Router /api/v1/soundtracks/{uuid} [get]
func (l *LibraryHandler) GetSoundtrack(ctx *echo.Context) error {
id, err := uuidParam(ctx)
if err != nil {
return err
}
soundtrack, err := l.library.Soundtrack(ctx.Request().Context(), id)
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, soundtrack)
}
// GetSong godoc
//
// @Summary Get song
// @Description Returns information about a song
// @Tags library
// @Produce json
// @Param uuid path string true "Song UUID"
// @Success 200 {object} backend.Song
// @Failure 400 {string} string "invalid uuid"
// @Failure 404 {string} string "not found"
// @Router /api/v1/songs/{uuid} [get]
func (l *LibraryHandler) GetSong(ctx *echo.Context) error {
id, err := uuidParam(ctx)
if err != nil {
return err
}
song, err := l.library.Song(ctx.Request().Context(), id)
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, song)
}
// GetSongFile godoc
//
// @Summary Stream song
// @Description Returns the audio file of a song. Supports Range requests.
// @Tags library
// @Produce audio/mpeg
// @Param uuid path string true "Song UUID"
// @Success 200 {file} file
// @Success 206 {file} file
// @Failure 400 {string} string "invalid uuid"
// @Failure 404 {string} string "not found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /api/v1/songs/{uuid}/file [get]
func (l *LibraryHandler) GetSongFile(ctx *echo.Context) error {
id, err := uuidParam(ctx)
if err != nil {
return err
}
path, err := l.library.SongPath(ctx.Request().Context(), id)
return serveSong(ctx, path, err)
file := op("get-song-file", "/songs/{uuid}/file", "Stream song",
"Returns the audio file of a song.", http.StatusNotFound)
file.Responses = fileResponses("The song (MP3)", audioMPEG)
register(s, a, route{op: file, locked: true},
func(ctx context.Context, in *uuidInput) (*huma.StreamResponse, error) {
return streamSong(l.SongPath(ctx, in.id()))
})
}
+1 -1
View File
@@ -48,7 +48,7 @@ func TestLibraryEndpointErrors(t *testing.T) {
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
}
for _, path := range []string{"/api/v1/soundtracks/not-a-uuid", "/api/v1/songs/123", "/api/v1/songs/123/file"} {
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, path).Code, path)
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, path).Code, path)
}
}
+6 -12
View File
@@ -1,16 +1,10 @@
package middleware
import (
"github.com/labstack/echo/v5"
)
import "github.com/danielgtaylor/huma/v2"
// DeprecationMiddleware adds deprecation warning to responses
// for old endpoints that are being phased out in favor of /api/v1/*
func DeprecationMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// Add deprecation warning header
c.Response().Header().Add("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
c.Response().Header().Add("Deprecation", "true")
return next(c)
}
// Deprecation marks responses from legacy routes as deprecated in favour of /api/v1.
func Deprecation(ctx huma.Context, next func(huma.Context)) {
ctx.AppendHeader("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
ctx.AppendHeader("Deprecation", "true")
next(ctx)
}
+14 -1
View File
@@ -1,2 +1,15 @@
// Package middleware provides Echo middleware for the MusicServer application.
// Package middleware provides Huma middleware for the MusicServer API.
package middleware
import (
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
)
// Middleware is a Huma middleware function.
type Middleware = func(ctx huma.Context, next func(huma.Context))
// clientIP returns the client IP as resolved by Echo (respecting trusted proxies).
func clientIP(ctx huma.Context) string {
return humaecho.Unwrap(ctx).RealIP()
}
+11 -10
View File
@@ -6,24 +6,25 @@ import (
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"github.com/danielgtaylor/huma/v2"
echoMiddleware "github.com/labstack/echo/v5/middleware"
"go.uber.org/zap"
)
// RateLimit allows each client IP limit requests per window, with bursts up to limit.
func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc {
config := echoMiddleware.DefaultRateLimiterConfig
config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
func RateLimit(api huma.API, limit int, window time.Duration) Middleware {
store := echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
Rate: float64(limit) / window.Seconds(),
Burst: limit,
ExpiresIn: window,
})
config.DenyHandler = func(c *echo.Context, identifier string, err error) error {
logging.GetLogger().Warn("Rate limit exceeded",
zap.String("ip", identifier),
zap.String("path", c.Request().URL.Path))
return echo.NewHTTPError(http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.")
return func(ctx huma.Context, next func(huma.Context)) {
ip := clientIP(ctx)
if allowed, _ := store.Allow(ip); !allowed {
logging.GetLogger().Warn("Rate limit exceeded", zap.String("ip", ip), zap.String("path", ctx.URL().Path))
_ = huma.WriteErr(api, ctx, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.")
return
}
next(ctx)
}
return echoMiddleware.RateLimiterWithConfig(config)
}
-22
View File
@@ -1,22 +0,0 @@
package middleware
import (
"net/http"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
)
// SyncCheck blocks requests with HTTP 423 Locked while a sync is in progress.
func SyncCheck(isSyncing func() bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if isSyncing() {
logging.GetLogger().Info("Syncing is in progress - request blocked")
return echo.NewHTTPError(http.StatusLocked, "Syncing is in progress")
}
return next(c)
}
}
}
+68 -41
View File
@@ -9,54 +9,81 @@ import (
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"github.com/danielgtaylor/huma/v2"
"go.uber.org/zap"
)
type tokenKey struct{}
// Token returns the session token of an authenticated request.
func Token(ctx context.Context) string {
token, _ := ctx.Value(tokenKey{}).(string)
return token
}
// BearerToken extracts the token from an "Authorization: Bearer <token>" header.
func BearerToken(c *echo.Context) (string, bool) {
token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
func BearerToken(header string) (string, bool) {
token, ok := strings.CutPrefix(header, "Bearer ")
return token, ok && token != "" && !strings.Contains(token, " ")
}
// TokenAuth validates session tokens. When disabled is true all requests are let through.
func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if disabled {
return next(c)
}
if c.Request().Header.Get("Authorization") == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "Authorization header required")
}
token, ok := BearerToken(c)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid authorization format. Use: Bearer <token>")
}
ctx := c.Request().Context()
session, err := q.GetSession(ctx, token)
if err != nil {
logging.GetLogger().Warn("Invalid token attempt",
zap.String("ip", c.RealIP()),
zap.String("error", err.Error()),
)
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired token")
}
if time.Now().After(session.ExpiresAt.Time) {
// Clean up in the background with a context that outlives the request.
go func(ctx context.Context) {
if err := q.DeleteSession(ctx, token); err != nil {
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
}
}(context.WithoutCancel(ctx))
return echo.NewHTTPError(http.StatusUnauthorized, "Token expired")
}
c.Set("session", session)
return next(c)
// TokenAuth validates the session token for operations that declare a security requirement.
// When disabled is true all requests are let through.
func TokenAuth(api huma.API, q repository.Querier, disabled bool) Middleware {
return func(ctx huma.Context, next func(huma.Context)) {
if disabled || len(ctx.Operation().Security) == 0 {
next(ctx)
return
}
header := ctx.Header("Authorization")
if header == "" {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Authorization header required")
return
}
token, ok := BearerToken(header)
if !ok {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid authorization format. Use: Bearer <token>")
return
}
session, err := q.GetSession(ctx.Context(), token)
if err != nil {
logging.GetLogger().Warn("Invalid token attempt", zap.String("ip", clientIP(ctx)), zap.Error(err))
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid or expired token")
return
}
if time.Now().After(session.ExpiresAt.Time) {
// Clean up in the background with a context that outlives the request.
go func(ctx context.Context) {
if err := q.DeleteSession(ctx, token); err != nil {
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
}
}(context.WithoutCancel(ctx.Context()))
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Token expired")
return
}
next(huma.WithValue(ctx, tokenKey{}, token))
}
}
type clientKey struct{}
// Client holds request details that handlers need but that are not part of the API input.
type Client struct {
IP string
UserAgent string
}
// ClientInfo stores the client's IP and user agent in the request context.
func ClientInfo(ctx huma.Context, next func(huma.Context)) {
next(huma.WithValue(ctx, clientKey{}, Client{IP: clientIP(ctx), UserAgent: ctx.Header("User-Agent")}))
}
// ClientFrom returns the client details stored by ClientInfo.
func ClientFrom(ctx context.Context) Client {
client, _ := ctx.Value(clientKey{}).(Client)
return client
}
+99
View File
@@ -0,0 +1,99 @@
package server
import (
"context"
"net/http"
"music-server/internal/backend"
"github.com/danielgtaylor/huma/v2"
)
type characterInput struct {
Name string `query:"name" required:"true" doc:"File name of the character image, from the character list"`
}
type redirectOutput struct {
Status int
Location string `header:"Location" doc:"Download URL of the asset"`
}
type healthOutput struct {
Body map[string]string
}
func (s *Server) registerMisc(a apis) {
get := func(id, path, tag, summary, description string, errs ...int) huma.Operation {
return huma.Operation{
OperationID: id,
Method: http.MethodGet,
Path: path,
Summary: summary,
Description: description,
Tags: []string{tag},
Errors: errs,
}
}
register(s, a, route{op: get("get-health", "/health", "server", "Health check",
"Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error."),
public: true, legacyPath: "/health"},
func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
return &healthOutput{Body: s.db.Health(ctx)}, nil
})
register(s, a, route{op: get("get-version", "/version", "server", "Latest server version",
"Returns the server version and its changelog."), legacyPath: "/version"},
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.VersionData], error) {
return ok(backend.GetLatestVersion(), nil)
})
register(s, a, route{op: get("get-version-history", "/version/history", "server", "Version history",
"Returns all server versions with their changelogs, newest first."), legacyPath: "/version/history"},
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.VersionData], error) {
return ok(backend.GetVersionHistory(), nil)
})
register(s, a, route{op: get("list-characters", "/character/list", "characters", "List characters",
"Returns the file names of all character images."), legacyPath: "/characters"},
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(s.characters.List())
})
image := get("get-character", "/character", "characters", "Character image",
"Returns a character image.", http.StatusBadRequest, http.StatusNotFound)
image.Responses = fileResponses("The image", "image/png", "image/jpeg")
register(s, a, route{op: image, legacyPath: "/character"},
func(ctx context.Context, in *characterInput) (*huma.StreamResponse, error) {
f, err := s.characters.Open(in.Name)
if err != nil {
return nil, apiError(err)
}
return streamOpenFile(f, "", false)
})
r := s.releases
register(s, a, route{op: get("get-latest-client-version", "/download", "download", "Latest client version",
"Returns the name of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download"},
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
return ok(r.LatestVersion(ctx))
})
register(s, a, route{op: get("list-client-assets", "/download/list", "download", "Latest client assets",
"Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download/list"},
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(r.AssetNames(ctx))
})
download := func(id, path, platform, suffix string) {
op := get(id, path, "download", "Download the latest client for "+platform,
"Redirects to the download URL of the latest MusicPlayer release for "+platform+".",
http.StatusNotFound, http.StatusBadGateway)
op.DefaultStatus = http.StatusFound
register(s, a, route{op: op, legacyPath: path},
func(ctx context.Context, _ *struct{}) (*redirectOutput, error) {
url, err := r.AssetURL(ctx, suffix)
if err != nil {
return nil, apiError(err)
}
return &redirectOutput{Status: http.StatusFound, Location: url}, nil
})
}
download("download-client-windows", "/download/windows", "Windows", ".exe")
download("download-client-linux", "/download/linux", "Linux", ".x86_64")
}
+134 -218
View File
@@ -1,240 +1,156 @@
package server
import (
"context"
"net/http"
"strconv"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"github.com/danielgtaylor/huma/v2"
"go.uber.org/zap"
)
type MusicHandler struct {
music *backend.MusicService
type songNumberInput struct {
Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"`
}
func NewMusicHandler(music *backend.MusicService) *MusicHandler {
return &MusicHandler{music: music}
}
const audioMPEG = "audio/mpeg"
// GetSong godoc
// @Summary Get a specific song
// @Description Returns the song at the given position in the queue
// @Tags music
// @Produce audio/mpeg
// @Param song query int true "Song number in the queue"
// @Success 200 {file} file
// @Success 206 {file} file
// @Failure 400 {string} string "song must be a number"
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music [get]
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
song, err := strconv.Atoi(ctx.QueryParam("song"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "song must be a number")
func (s *Server) registerMusic(a apis) {
m := s.music
audio := func(id, path, summary, description string, errs ...int) route {
return route{
op: huma.Operation{
OperationID: id,
Method: http.MethodGet,
Path: path,
Summary: summary,
Description: description,
Tags: []string{"music"},
Responses: fileResponses("The song (MP3)", audioMPEG),
Errors: errs,
},
legacyPath: path,
locked: true,
}
}
path, err := m.music.Song(song)
return serveSong(ctx, path, err)
}
// GetSoundCheckSong godoc
// @Summary Get sound check song
// @Description Returns the sound check song
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/soundTest [get]
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
path, err := m.music.SoundCheckSong()
return serveSong(ctx, path, err)
}
// ResetMusic godoc
// @Summary Reset music state
// @Description Resets the music state
// @Tags music
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/reset [get]
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
if err := m.music.Reset(ctx.Request().Context()); err != nil {
return songError(err)
status := func(id, method, path, summary, description string) route {
return route{
op: huma.Operation{
OperationID: id,
Method: method,
Path: path,
Summary: summary,
Description: description,
Tags: []string{"music"},
DefaultStatus: http.StatusOK,
},
legacyPath: path,
locked: true,
}
}
return ctx.NoContent(http.StatusOK)
}
// GetRandomSong godoc
// @Summary Get random song
// @Description Returns a random song
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand [get]
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
path, err := m.music.RandomSong(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetRandomSongLowChance godoc
// @Summary Get random song with low chance
// @Description Returns a random song, favouring soundtracks that have been played less
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/low [get]
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
path, err := m.music.RandomSongLowChance(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetRandomSongClassic godoc
// @Summary Get random classic song
// @Description Returns a random song where every song has the same chance
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/classic [get]
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
path, err := m.music.RandomSongClassic(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetSongInfo godoc
// @Summary Get current song info
// @Description Returns information about the current song
// @Tags music
// @Produce json
// @Success 200 {object} backend.SongInfo
// @Router /music/info [get]
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, m.music.SongInfo(ctx.Request().Context()))
}
// GetPlayedSongs godoc
// @Summary Get played songs list
// @Description Returns a list of played songs
// @Tags music
// @Produce json
// @Success 200 {array} backend.SongInfo
// @Router /music/list [get]
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, m.music.PlayedSongs(ctx.Request().Context()))
}
// GetNextSong godoc
// @Summary Get next song
// @Description Returns the next song in the queue
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/next [get]
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
path, err := m.music.NextSong()
return serveSong(ctx, path, err)
}
// GetPreviousSong godoc
// @Summary Get previous song
// @Description Returns the previous song in the queue
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/previous [get]
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
path, err := m.music.PreviousSong()
return serveSong(ctx, path, err)
}
// GetAllSoundtracks godoc
// @Summary Get all soundtracks
// @Description Returns a list of all soundtracks in order
// @Tags music
// @Produce json
// @Success 200 {array} string
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/order [get]
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
names, err := m.music.SoundtrackNames(ctx.Request().Context())
if err != nil {
return songError(err)
list := func(id, path, summary, description string, locked bool) route {
return route{
op: huma.Operation{
OperationID: id,
Method: http.MethodGet,
Path: path,
Summary: summary,
Description: description,
Tags: []string{"music"},
},
legacyPath: path,
locked: locked,
}
}
return ctx.JSON(http.StatusOK, names)
}
// GetAllSoundtracksRandom godoc
// @Summary Get all soundtracks random
// @Description Returns a list of all soundtracks in random order
// @Tags music
// @Produce json
// @Success 200 {array} string
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/random [get]
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
names, err := m.music.SoundtrackNamesRandom(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, names)
}
register(s, a, audio("get-queue-song", "/music", "Play a song from the queue",
"Jumps to the song at the given queue position (clamped to the queue) and returns it.", http.StatusNotFound),
func(ctx context.Context, in *songNumberInput) (*huma.StreamResponse, error) {
return streamSong(m.Song(in.Song))
})
register(s, a, audio("get-sound-test-song", "/music/soundTest", "Sound check song",
"Returns a random song for checking the volume.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.SoundCheckSong())
})
register(s, a, audio("get-random-song", "/music/rand", "Random song",
"Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.RandomSong(ctx))
})
register(s, a, audio("get-random-song-low-chance", "/music/rand/low", "Random song, favouring rarely played",
"Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.RandomSongLowChance(ctx))
})
register(s, a, audio("get-random-song-classic", "/music/rand/classic", "Random song, every song equal",
"Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.RandomSongClassic(ctx))
})
register(s, a, audio("get-next-song", "/music/next", "Next song in the queue",
"Moves to the next song in the queue and returns it. Stays on the last song at the end.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.NextSong())
})
register(s, a, audio("get-previous-song", "/music/previous", "Previous song in the queue",
"Moves to the previous song in the queue and returns it. Stays on the first song at the start.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.PreviousSong())
})
// PutPlayed godoc
// @Summary Mark song as played
// @Description Marks the song at the given queue position as played
// @Tags music
// @Param song query int true "Song number in the queue"
// @Success 200
// @Failure 400 {string} string "Bad Request"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/played [put]
func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
song, err := strconv.Atoi(ctx.QueryParam("song"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "song must be a number")
register(s, a, list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false),
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.SongInfo], error) {
return ok(m.SongInfo(ctx), nil)
})
register(s, a, list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false),
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SongInfo], error) {
return ok(m.PlayedSongs(ctx), nil)
})
randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(m.SoundtrackNamesRandom(ctx))
}
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
if err := m.music.SetPlayed(ctx.Request().Context(), song); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
}
register(s, a, list("get-soundtrack-names-random", "/music/all", "Soundtrack names, random order",
"Returns the names of all soundtracks in random order.", true), randomNames)
register(s, a, list("get-soundtrack-names-random-alias", "/music/all/random", "Soundtrack names, random order",
"Same as /music/all.", true), randomNames)
register(s, a, list("get-soundtrack-names", "/music/all/order", "Soundtrack names, sorted",
"Returns the names of all soundtracks in alphabetical order.", true),
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(m.SoundtrackNames(ctx))
})
// AddLatestToQue godoc
// @Summary Add latest to queue
// @Description Adds the latest song to the queue
// @Tags music
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addQue [get]
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
m.music.AddLatestToQueue()
return ctx.NoContent(http.StatusOK)
}
// AddLatestPlayed godoc
// @Summary Add latest to played
// @Description Marks the current song as played
// @Tags music
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addPlayed [get]
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
if err := m.music.AddLatestPlayed(ctx.Request().Context()); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
register(s, a, status("reset-queue", http.MethodGet, "/music/reset", "Reset the queue",
"Clears the queue and reloads the soundtrack list."),
func(ctx context.Context, _ *struct{}) (*noBody, error) {
if err := m.Reset(ctx); err != nil {
return nil, apiError(err)
}
return &noBody{}, nil
})
register(s, a, status("add-latest-to-queue", http.MethodGet, "/music/addQue", "Add the last random song to the queue",
"Adds the song returned by the last random song request to the end of the queue."),
func(ctx context.Context, _ *struct{}) (*noBody, error) {
m.AddLatestToQueue()
return &noBody{}, nil
})
register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played",
"Increments the play counters of the current song and its soundtrack."),
func(ctx context.Context, _ *struct{}) (*noBody, error) {
if err := m.AddLatestPlayed(ctx); err != nil {
return nil, apiError(err)
}
return &noBody{}, nil
})
register(s, a, status("mark-song-played", http.MethodPut, "/music/played", "Mark a queued song as played",
"Increments the play counters of the song at the given queue position. Positions outside the queue are ignored."),
func(ctx context.Context, in *songNumberInput) (*noBody, error) {
logging.GetLogger().Info("Marking song as played", zap.Int("song_no", in.Song))
if err := m.SetPlayed(ctx, in.Song); err != nil {
return nil, apiError(err)
}
return &noBody{}, nil
})
}
+260
View File
@@ -0,0 +1,260 @@
package server
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"music-server/internal/config"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/gorillamux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// loadSpec loads the OpenAPI 3.0 version of the spec the server publishes.
func loadSpec(t *testing.T, e *testEnv) *openapi3.T {
t.Helper()
rec := e.do(http.MethodGet, "/openapi-3.0.json")
require.Equal(t, http.StatusOK, rec.Code)
spec, err := openapi3.NewLoader().LoadFromData(rec.Body.Bytes())
require.NoError(t, err)
return spec
}
func TestOpenAPISpecIsValid(t *testing.T) {
e := newTestEnv(t)
spec := loadSpec(t, e)
require.NoError(t, spec.Validate(context.Background()))
// The 3.1 spec is valid JSON with the same paths.
var spec31 struct {
OpenAPI string `json:"openapi"`
Paths map[string]any `json:"paths"`
}
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/openapi.json").Body.Bytes(), &spec31))
assert.Equal(t, "3.1.0", spec31.OpenAPI)
assert.Len(t, spec31.Paths, spec.Paths.Len())
}
func TestOpenAPIDocumentsTheV1API(t *testing.T) {
spec := loadSpec(t, newTestEnv(t))
var operations []string
for path, item := range spec.Paths.Map() {
assert.True(t, strings.HasPrefix(path, "/api/v1/"), "only /api/v1 is documented: %s", path)
for method, op := range item.Operations() {
operations = append(operations, method+" "+path)
public := path == "/api/v1/health" || (path == "/api/v1/token" && method == http.MethodPost)
if public {
assert.Nil(t, op.Security, "%s %s needs no token", method, path)
} else {
require.NotNil(t, op.Security, "%s %s needs a token", method, path)
assert.Equal(t, openapi3.SecurityRequirements{{bearerAuthName: {}}}, *op.Security)
assert.Contains(t, op.Responses.Map(), "401", "%s %s documents 401", method, path)
}
}
}
slices.Sort(operations)
assert.Equal(t, []string{
"DELETE /api/v1/token",
"GET /api/v1/character",
"GET /api/v1/character/list",
"GET /api/v1/download",
"GET /api/v1/download/linux",
"GET /api/v1/download/list",
"GET /api/v1/download/windows",
"GET /api/v1/health",
"GET /api/v1/music",
"GET /api/v1/music/addPlayed",
"GET /api/v1/music/addQue",
"GET /api/v1/music/all",
"GET /api/v1/music/all/order",
"GET /api/v1/music/all/random",
"GET /api/v1/music/info",
"GET /api/v1/music/list",
"GET /api/v1/music/next",
"GET /api/v1/music/previous",
"GET /api/v1/music/rand",
"GET /api/v1/music/rand/classic",
"GET /api/v1/music/rand/low",
"GET /api/v1/music/reset",
"GET /api/v1/music/soundTest",
"GET /api/v1/songs/{uuid}",
"GET /api/v1/songs/{uuid}/file",
"GET /api/v1/soundtracks",
"GET /api/v1/soundtracks/{uuid}",
"GET /api/v1/statistics/songs/least-played",
"GET /api/v1/statistics/songs/most-played",
"GET /api/v1/statistics/soundtracks/last-played",
"GET /api/v1/statistics/soundtracks/least-played",
"GET /api/v1/statistics/soundtracks/most-played",
"GET /api/v1/statistics/soundtracks/never-played",
"GET /api/v1/statistics/soundtracks/oldest-played",
"GET /api/v1/statistics/summary",
"GET /api/v1/sync/progress",
"GET /api/v1/version",
"GET /api/v1/version/history",
"POST /api/v1/sync",
"POST /api/v1/sync/full",
"POST /api/v1/token",
"POST /api/v1/token/cleanup",
"PUT /api/v1/music/played",
}, operations, "/sync/reset is hidden and legacy routes are not documented")
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme)
}
// specValidator checks responses against the published spec.
type specValidator struct {
t *testing.T
e *testEnv
router routers.Router
seen map[string]bool
}
func newSpecValidator(t *testing.T, e *testEnv) *specValidator {
spec := loadSpec(t, e)
spec.Servers = openapi3.Servers{{URL: "http://example.com"}}
router, err := gorillamux.NewRouter(spec)
require.NoError(t, err)
return &specValidator{t: t, e: e, router: router, seen: map[string]bool{}}
}
// check sends a request and validates the response status, headers and body against the spec.
func (v *specValidator) check(method, path, body string, wantStatus int, headers ...string) *httptest.ResponseRecorder {
v.t.Helper()
rec := v.e.doBody(method, path, body, headers...)
require.Equal(v.t, wantStatus, rec.Code, "%s %s: %s", method, path, rec.Body.String())
req := httptest.NewRequest(method, path, strings.NewReader(body))
route, params, err := v.router.FindRoute(req)
require.NoError(v.t, err, "%s %s is not in the spec", method, path)
v.seen[method+" "+route.Path] = true
input := &openapi3filter.ResponseValidationInput{
RequestValidationInput: &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route},
Status: rec.Code,
Header: rec.Header(),
Options: &openapi3filter.Options{IncludeResponseStatus: true},
}
input.SetBodyBytes(rec.Body.Bytes())
if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "image/") {
input.Body = io.NopCloser(bytes.NewReader(nil))
input.Options.ExcludeResponseBody = true
}
assert.NoError(v.t, openapi3filter.ValidateResponse(context.Background(), input), "%s %s", method, path)
return rec
}
func TestResponsesMatchOpenAPI(t *testing.T) {
gitea := newGitea(t, http.StatusOK)
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea })
v := newSpecValidator(t, e)
song := e.addSong("Game A", "Song", "0123456789")
songURL := "/api/v1/songs/" + song.Uuid.String()
// Empty states and errors.
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusNotFound)
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music?song=x", "", http.StatusUnprocessableEntity)
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
// Music.
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/addQue", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/addPlayed", "", http.StatusOK)
v.check(http.MethodPut, "/api/v1/music/played?song=0", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music?song=0", "", http.StatusPartialContent, "Range", "bytes=0-1")
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/previous", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/rand/low", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/rand/classic", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/soundTest", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/all", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/all/random", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/all/order", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/music/reset", "", http.StatusOK)
// Library.
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
v.check(http.MethodGet, songURL, "", http.StatusOK)
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
// Characters, downloads, server info.
v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest)
v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound)
v.check(http.MethodGet, "/api/v1/download", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/download/list", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/download/windows", "", http.StatusFound)
v.check(http.MethodGet, "/api/v1/download/linux", "", http.StatusNotFound)
v.check(http.MethodGet, "/api/v1/health", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/version", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/version/history", "", http.StatusOK)
// Statistics (empty database).
for _, path := range []string{
"/soundtracks/most-played", "/soundtracks/least-played", "/soundtracks/last-played",
"/soundtracks/oldest-played", "/songs/most-played", "/songs/least-played",
} {
v.check(http.MethodGet, "/api/v1/statistics"+path+"?limit=5", "", http.StatusOK)
}
v.check(http.MethodGet, "/api/v1/statistics/soundtracks/never-played", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
// Sync, including the locked state and the result after it finishes.
release := e.blockSync()
v.check(http.MethodPost, "/api/v1/sync", "", http.StatusOK)
v.check(http.MethodPost, "/api/v1/sync/full", "", http.StatusLocked)
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusLocked)
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
release()
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
// Tokens and authentication.
auth := newTestEnv(t, withAuth("secret"))
va := newSpecValidator(t, auth)
va.check(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`, http.StatusUnauthorized)
rec := va.check(http.MethodPost, "/api/v1/token", `{"password":"secret","client_type":"api"}`, http.StatusOK)
var token TokenResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &token))
bearer := []string{"Authorization", "Bearer " + token.Token}
va.check(http.MethodGet, "/api/v1/version", "", http.StatusUnauthorized)
va.check(http.MethodPost, "/api/v1/token/cleanup", "", http.StatusOK, bearer...)
va.check(http.MethodDelete, "/api/v1/token", "", http.StatusOK, bearer...)
// Statistics with data, through the canned statistics store.
stats := newTestEnv(t)
stats.server.stats = statsService()
stats.handler = stats.server.RegisterRoutes()
vs := newSpecValidator(t, stats)
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks/most-played", "", http.StatusOK)
vs.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
// Every documented operation was checked at least once (statistics and tokens use other environments).
spec := loadSpec(t, e)
for path, item := range spec.Paths.Map() {
for method := range item.Operations() {
key := method + " " + path
assert.True(t, v.seen[key] || va.seen[key] || vs.seen[key], "no response check for %s", key)
}
}
}
+18 -129
View File
@@ -2,9 +2,7 @@ package server
import (
"net/http"
"time"
_ "music-server/cmd/docs" // registers the OpenAPI spec
"music-server/cmd/web"
"music-server/internal/logging"
"music-server/internal/server/middleware"
@@ -12,51 +10,9 @@ import (
"github.com/a-h/templ"
"github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware"
echoSwagger "github.com/swaggo/echo-swagger/v2"
"github.com/swaggo/swag"
"go.uber.org/zap"
)
// @title MusicServer API
// @version 1.0
// @description API for the MusicServer application
// @termsOfService http://sanplex.xyz/terms/
// @contact.name Sebastian Olsson
// @contact.email zarnor91@gmail.com
// @license.name MIT
// @license.url http://opensource.org/licenses/MIT
// @BasePath /
// handlers holds all HTTP handlers.
type handlers struct {
health *HealthHandler
version *VersionHandler
character *CharacterHandler
download *DownloadHandler
sync *SyncHandler
music *MusicHandler
library *LibraryHandler
statistics *StatisticsHandler
token *TokenHandler
}
func (s *Server) handlers() handlers {
return handlers{
health: NewHealthHandler(s.db),
version: NewVersionHandler(),
character: NewCharacterHandler(s.characters),
download: NewDownloadHandler(s.releases),
sync: NewSyncHandler(s.sync),
music: NewMusicHandler(s.music),
library: NewLibraryHandler(s.library),
statistics: NewStatisticsHandler(s.stats),
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
}
}
func (s *Server) RegisterRoutes() http.Handler {
e := echo.New()
e.HTTPErrorHandler = httpErrorHandler
@@ -78,102 +34,35 @@ func (s *Server) RegisterRoutes() http.Handler {
MaxAge: 300,
}))
e.GET("/openapi", serveOpenAPI)
e.GET("/swagger/*", echoSwagger.WrapHandler)
// The API: /api/v1 is documented at /docs and /openapi.json; legacy routes are hidden.
a := newAPIs(e)
a.v1.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a.v1, s.q, s.cfg.DisableSessionAuth))
a.legacy.UseMiddleware(middleware.ClientInfo, middleware.Deprecation)
s.registerToken(a)
s.registerMisc(a)
s.registerSync(a)
s.registerMusic(a)
s.registerLibrary(a)
s.registerStatistics(a)
// Older links to the spec and docs.
e.GET("/openapi", redirect("/openapi.json"))
e.GET("/swagger/*", redirect("/docs"))
// Search page and frontend.
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
e.Static("/", s.cfg.FrontendPath)
h := s.handlers()
syncLock := middleware.SyncCheck(s.sync.IsSyncing)
// Legacy endpoints (deprecated - use /api/v1/ instead)
legacy := e.Group("", middleware.DeprecationMiddleware)
registerCommon(legacy, h, syncLock)
legacy.GET("/health", h.health.HealthCheck)
legacy.GET("/characters", h.character.GetCharacterList)
// API v1
apiV1 := e.Group("/api/v1")
tokenGroup := apiV1.Group("/token")
tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute))
tokenGroup.DELETE("", h.token.DeleteTokenHandler)
protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth))
protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler)
registerCommon(protected, h, syncLock)
protected.GET("/character/list", h.character.GetCharacterList)
protected.GET("/soundtracks", h.library.GetSoundtracks)
protected.GET("/soundtracks/:uuid", h.library.GetSoundtrack)
protected.GET("/songs/:uuid", h.library.GetSong)
protected.GET("/songs/:uuid/file", h.library.GetSongFile, syncLock)
stats := protected.Group("/statistics")
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks)
stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks)
stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks)
stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs)
stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs)
stats.GET("/summary", h.statistics.GetStatisticsSummary)
for _, r := range e.Router().Routes() {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
}
return e
}
// registerCommon registers the routes shared by the legacy and the v1 API.
// syncLock is applied to routes that must not run during a sync.
func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) {
g.GET("/version", h.version.GetLatestVersion)
g.GET("/version/history", h.version.GetVersionHistory)
g.GET("/character", h.character.GetCharacter)
g.GET("/download", h.download.CheckLatest)
g.GET("/download/list", h.download.ListAssetsOfLatest)
g.GET("/download/windows", h.download.DownloadLatestWindows)
g.GET("/download/linux", h.download.DownloadLatestLinux)
g.GET("/sync/progress", h.sync.SyncProgress)
for _, path := range []string{"/sync", "/sync/new", "/sync/quick"} {
g.POST(path, h.sync.SyncSoundtracksNewOnlyChanges, syncLock)
func redirect(to string) echo.HandlerFunc {
return func(c *echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, to)
}
for _, path := range []string{"/sync/full", "/sync/new/full"} {
g.POST(path, h.sync.SyncSoundtracksNewFull, syncLock)
}
g.POST("/sync/reset", h.sync.ResetDB, syncLock)
music := g.Group("/music", syncLock)
music.GET("", h.music.GetSong)
music.GET("/soundTest", h.music.GetSoundCheckSong)
music.GET("/reset", h.music.ResetMusic)
music.GET("/rand", h.music.GetRandomSong)
music.GET("/rand/low", h.music.GetRandomSongLowChance)
music.GET("/rand/classic", h.music.GetRandomSongClassic)
music.GET("/next", h.music.GetNextSong)
music.GET("/previous", h.music.GetPreviousSong)
music.GET("/all", h.music.GetAllSoundtracksRandom)
music.GET("/all/order", h.music.GetAllSoundtracks)
music.GET("/all/random", h.music.GetAllSoundtracksRandom)
music.PUT("/played", h.music.PutPlayed)
music.GET("/addQue", h.music.AddLatestToQue)
music.GET("/addPlayed", h.music.AddLatestPlayed)
// Info and list are read-only and stay available during a sync.
g.GET("/music/info", h.music.GetSongInfo)
g.GET("/music/list", h.music.GetPlayedSongs)
}
// serveOpenAPI serves the embedded OpenAPI spec.
func serveOpenAPI(c *echo.Context) error {
doc, err := swag.ReadDoc()
if err != nil {
return err
}
return c.Blob(http.StatusOK, echo.MIMEApplicationJSON, []byte(doc))
}
+24 -12
View File
@@ -28,7 +28,7 @@ func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) {
e := newTestEnv(t)
e.addSong("Game A", "Song", "a")
for _, prefix := range []string{"", "/api/v1"} {
for _, path := range []string{"/sync", "/sync/new", "/sync/quick", "/sync/full", "/sync/new/full", "/sync/reset"} {
for _, path := range []string{"/sync", "/sync/full", "/sync/reset"} {
// GET falls through to the frontend's static files, which answer 404.
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path)
}
@@ -99,21 +99,33 @@ func TestHealthEndpoint(t *testing.T) {
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
}
func TestOpenAPIAndSwagger(t *testing.T) {
func TestSyncAliasesAreRemoved(t *testing.T) {
e := newTestEnv(t)
for _, path := range []string{"/openapi", "/swagger/doc.json"} {
rec := e.do(http.MethodGet, path)
require.Equal(t, http.StatusOK, rec.Code, path)
var spec struct {
Paths map[string]map[string]any `json:"paths"`
for _, prefix := range []string{"", "/api/v1"} {
for _, path := range []string{"/sync/new", "/sync/quick", "/sync/new/full"} {
assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, prefix+path).Code, prefix+path)
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec), path)
assert.Contains(t, spec.Paths, "/api/v1/token", path)
assert.Contains(t, spec.Paths["/sync/reset"], "post", path)
}
assert.False(t, e.server.sync.IsSyncing())
}
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/swagger/index.html").Code)
func TestV1HealthEndpoint(t *testing.T) {
e := newTestEnv(t)
rec := e.do(http.MethodGet, "/api/v1/health")
assert.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
}
func TestDocsAndSpecLinks(t *testing.T) {
e := newTestEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code)
for _, path := range []string{"/openapi.json", "/openapi.yaml", "/openapi-3.0.json"} {
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
}
rec := e.do(http.MethodGet, "/openapi")
assert.Equal(t, http.StatusMovedPermanently, rec.Code)
assert.Equal(t, "/openapi.json", rec.Header().Get("Location"))
assert.Equal(t, "/docs", e.do(http.MethodGet, "/swagger/index.html").Header().Get("Location"))
}
func TestSearchPageAndFrontend(t *testing.T) {
+1 -1
View File
@@ -18,7 +18,7 @@ import (
// Database is the database connection used by the server.
type Database interface {
HealthChecker
Health(ctx context.Context) map[string]string
Close()
}
+51 -168
View File
@@ -1,188 +1,71 @@
package server
import (
"context"
"net/http"
"strconv"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
"github.com/danielgtaylor/huma/v2"
)
const (
defaultStatsLimit = 10
maxStatsLimit = 100
)
const maxStatsLimit = 100
// StatisticsHandler handles statistics-related HTTP requests
type StatisticsHandler struct {
stats *backend.StatisticsService
type limitInput struct {
Limit int `query:"limit" default:"10" minimum:"1" doc:"Number of results. Values above 100 are capped to 100."`
}
// NewStatisticsHandler creates a new StatisticsHandler
func NewStatisticsHandler(stats *backend.StatisticsService) *StatisticsHandler {
return &StatisticsHandler{stats: stats}
func (in *limitInput) capped() int32 {
return int32(min(in.Limit, maxStatsLimit))
}
// parseLimit reads the "limit" query parameter, defaulting to 10 and capping at 100.
func parseLimit(ctx *echo.Context) (int32, bool) {
limitStr := ctx.QueryParam("limit")
if limitStr == "" {
return defaultStatsLimit, true
func (s *Server) registerStatistics(a apis) {
st := s.stats
op := func(id, path, summary, description string) route {
return route{op: huma.Operation{
OperationID: id,
Method: http.MethodGet,
Path: "/statistics" + path,
Summary: summary,
Description: description,
Tags: []string{"statistics"},
}}
}
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
return 0, false
soundtracks := func(id, path, summary, description string,
query func(context.Context, int32) ([]backend.SoundtrackWithSongs, error)) {
register(s, a, op(id, path, summary, description),
func(ctx context.Context, in *limitInput) (*jsonBody[[]backend.SoundtrackWithSongs], error) {
return ok(query(ctx, in.capped()))
})
}
return int32(min(limit, maxStatsLimit)), true
}
// withLimit runs a statistics query that takes a limit and writes the result as JSON.
func withLimit[T any](ctx *echo.Context, name string, query func(*echo.Context, int32) (T, error)) error {
limit, ok := parseLimit(ctx)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid limit parameter")
songs := func(id, path, summary, description string,
query func(context.Context, int32) ([]backend.SongInfoForStats, error)) {
register(s, a, op(id, path, summary, description),
func(ctx context.Context, in *limitInput) (*jsonBody[[]backend.SongInfoForStats], error) {
return ok(query(ctx, in.capped()))
})
}
result, err := query(ctx, limit)
return statsResult(ctx, name, result, err)
}
func statsResult[T any](ctx *echo.Context, name string, result T, err error) error {
if err != nil {
logging.GetLogger().Error("Failed to get statistics", zap.String("statistic", name), zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get statistics")
}
return ctx.JSON(http.StatusOK, result)
}
soundtracks("get-most-played-soundtracks", "/soundtracks/most-played", "Most played soundtracks",
"Returns the most played soundtracks with their songs.", st.MostPlayedSoundtracks)
soundtracks("get-least-played-soundtracks", "/soundtracks/least-played", "Least played soundtracks",
"Returns the least played soundtracks with their songs.", st.LeastPlayedSoundtracks)
soundtracks("get-last-played-soundtracks", "/soundtracks/last-played", "Recently played soundtracks",
"Returns the most recently played soundtracks with their songs.", st.LastPlayedSoundtracks)
soundtracks("get-oldest-played-soundtracks", "/soundtracks/oldest-played", "Least recently played soundtracks",
"Returns the soundtracks played longest ago (played at least once) with their songs.", st.OldestPlayedSoundtracks)
songs("get-most-played-songs", "/songs/most-played", "Most played songs",
"Returns the most played songs with their soundtrack.", st.MostPlayedSongs)
songs("get-least-played-songs", "/songs/least-played", "Least played songs",
"Returns the least played songs with their soundtrack.", st.LeastPlayedSongs)
// GetMostPlayedSoundtracks returns top N most played soundtracks with songs
//
// @Summary Get most played soundtracks
// @Description Returns the top N most played soundtracks with their songs
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/most-played [get]
func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
return withLimit(ctx, "most played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.MostPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
//
// @Summary Get least played soundtracks
// @Description Returns the top N least played soundtracks with their songs
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/least-played [get]
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
return withLimit(ctx, "least played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.LeastPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetMostPlayedSongs returns top N most played songs
//
// @Summary Get most played songs
// @Description Returns the top N most played songs with their soundtrack info
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SongInfoForStats
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/songs/most-played [get]
func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
return withLimit(ctx, "most played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
return h.stats.MostPlayedSongs(c.Request().Context(), limit)
})
}
// GetLeastPlayedSongs returns top N least played songs
//
// @Summary Get least played songs
// @Description Returns the top N least played songs with their soundtrack info
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SongInfoForStats
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/songs/least-played [get]
func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
return withLimit(ctx, "least played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
return h.stats.LeastPlayedSongs(c.Request().Context(), limit)
})
}
// GetLastPlayedSoundtracks returns the most recently played soundtracks
//
// @Summary Get last played soundtracks
// @Description Returns the most recently played soundtracks with their songs
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/last-played [get]
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
return withLimit(ctx, "last played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.LastPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
//
// @Summary Get oldest played soundtracks
// @Description Returns the least recently played soundtracks with their songs
// @Tags statistics
// @Produce json
// @Param limit query int false "Number of results (default: 10, max: 100)"
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
return withLimit(ctx, "oldest played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
return h.stats.OldestPlayedSoundtracks(c.Request().Context(), limit)
})
}
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
//
// @Summary Get never played soundtracks
// @Description Returns all soundtracks that have never been played
// @Tags statistics
// @Produce json
// @Success 200 {array} backend.SoundtrackWithSongs
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/soundtracks/never-played [get]
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
result, err := h.stats.NeverPlayedSoundtracks(ctx.Request().Context())
return statsResult(ctx, "never played soundtracks", result, err)
}
// GetStatisticsSummary returns overall statistics
//
// @Summary Get statistics summary
// @Description Returns overall play statistics
// @Tags statistics
// @Produce json
// @Success 200 {object} backend.StatisticsSummary
// @Failure 500 {object} map[string]string
// @Router /api/v1/statistics/summary [get]
func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error {
result, err := h.stats.Summary(ctx.Request().Context())
return statsResult(ctx, "summary", result, err)
register(s, a, op("get-never-played-soundtracks", "/soundtracks/never-played", "Never played soundtracks",
"Returns all soundtracks that have never been played, with their songs."),
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SoundtrackWithSongs], error) {
return ok(st.NeverPlayedSoundtracks(ctx))
})
register(s, a, op("get-statistics-summary", "/summary", "Statistics summary", "Returns overall play statistics."),
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.StatisticsSummary], error) {
return ok(st.Summary(ctx))
})
}
+70 -64
View File
@@ -1,80 +1,86 @@
package server
import (
"context"
"net/http"
"reflect"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"github.com/danielgtaylor/huma/v2"
)
type SyncHandler struct {
sync *backend.SyncService
}
func NewSyncHandler(sync *backend.SyncService) *SyncHandler {
return &SyncHandler{sync: sync}
}
// SyncProgress godoc
// @Summary Get sync progress
// @Description Returns the progress of the running sync, or the result of the last sync
// @Tags sync
// @Produce json
// @Success 200 {object} backend.SyncResponse
// @Router /sync/progress [get]
func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
if s.sync.IsSyncing() {
return ctx.JSON(http.StatusOK, s.sync.Progress())
func (s *Server) registerSync(a apis) {
registry := a.v1.OpenAPI().Components.Schemas
progress := huma.Operation{
OperationID: "get-sync-progress",
Method: http.MethodGet,
Path: "/sync/progress",
Summary: "Sync progress or result",
Description: "While a sync is running, returns its progress. Otherwise returns the result of the last sync.",
Tags: []string{"sync"},
Responses: map[string]*huma.Response{
"200": {
Description: "Progress of the running sync, or the result of the last sync",
Content: map[string]*huma.MediaType{"application/json": {Schema: &huma.Schema{OneOf: []*huma.Schema{
registry.Schema(reflect.TypeFor[backend.ProgressResponse](), true, ""),
registry.Schema(reflect.TypeFor[backend.SyncResponse](), true, ""),
}}}},
},
},
}
return ctx.JSON(http.StatusOK, s.sync.Result())
}
register(s, a, route{op: progress, legacyPath: "/sync/progress"},
func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) {
if s.sync.IsSyncing() {
return ok[any](s.sync.Progress(), nil)
}
return ok[any](s.sync.Result(), nil)
})
// SyncSoundtracksNewOnlyChanges godoc
// @Summary Sync soundtracks with only changes
// @Description Starts syncing soundtracks that have changed
// @Tags sync
// @Produce json
// @Success 200 {string} string "Start syncing soundtracks"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync [post]
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
return s.start(ctx, false, "Start syncing soundtracks")
}
// SyncSoundtracksNewFull godoc
// @Summary Sync all soundtracks fully
// @Description Starts a full sync of all soundtracks
// @Tags sync
// @Produce json
// @Success 200 {string} string "Start syncing soundtracks full"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/full [post]
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
return s.start(ctx, true, "Start syncing soundtracks full")
}
func (s *SyncHandler) start(ctx *echo.Context, full bool, message string) error {
if !s.sync.Start(full) {
return echo.NewHTTPError(http.StatusLocked, "Syncing is in progress")
start := func(id, path, summary, description string, full bool) {
register(s, a, route{
op: huma.Operation{
OperationID: id,
Method: http.MethodPost,
Path: path,
Summary: summary,
Description: description,
Tags: []string{"sync"},
Errors: []int{http.StatusLocked},
},
legacyPath: path,
}, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
message := "Start syncing soundtracks"
if full {
message += " full"
}
if !s.sync.Start(full) {
return nil, huma.Error423Locked("Syncing is in progress")
}
logging.GetLogger().Info(message)
return ok(message, nil)
})
}
logging.GetLogger().Info(message)
return ctx.JSON(http.StatusOK, message)
}
start("start-sync", "/sync", "Sync changed soundtracks",
"Starts syncing the music folder in the background, skipping soundtracks that have not changed. "+
"Poll /sync/progress for the result.", false)
start("start-full-sync", "/sync/full", "Sync all soundtracks",
"Starts a full sync in the background that re-reads every soundtrack and removes songs whose files are gone. "+
"Poll /sync/progress for the result.", true)
// ResetDB godoc
// @Summary Reset soundtracks database
// @Description Resets the soundtracks database by deleting all soundtracks and songs
// @Tags sync
// @Produce json
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /sync/reset [post]
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
logging.GetLogger().Info("Resetting soundtracks database")
if err := s.sync.ResetDB(ctx.Request().Context()); err != nil {
return songError(err)
reset := huma.Operation{
OperationID: "reset-library",
Method: http.MethodPost,
Path: "/sync/reset",
Summary: "Delete all soundtracks and songs",
Description: "For debugging only.",
Tags: []string{"sync"},
Hidden: true,
}
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
register(s, a, route{op: reset, legacyPath: "/sync/reset", locked: true},
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
logging.GetLogger().Info("Resetting soundtracks database")
return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx))
})
}
+79 -113
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
@@ -11,8 +12,8 @@ import (
"music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/danielgtaylor/huma/v2"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
@@ -20,27 +21,23 @@ const tokenLifetime = 24 * time.Hour
// TokenRequest represents a request to generate a new token
type TokenRequest struct {
Password string `json:"password"` // API password, required when API_PASSWORD is set
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
Password string `json:"password,omitempty" doc:"API password, required when the server has one configured"`
ClientType string `json:"client_type,omitempty" doc:"Kind of client, for example web, godot or api" default:"web"`
}
// TokenResponse represents the response with a new token
type TokenResponse struct {
Token string `json:"token"`
Token string `json:"token" doc:"Send as Authorization: Bearer <token>"`
ExpiresAt time.Time `json:"expires_at"`
ClientType string `json:"client_type"`
}
// TokenHandler creates and removes session tokens.
type TokenHandler struct {
q repository.Querier
apiPassword string
disableSessionAuth bool
type tokenInput struct {
Body TokenRequest `required:"false"`
}
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password.
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler {
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
type statusMessage struct {
Status string `json:"status"`
}
func generateToken() (string, error) {
@@ -51,115 +48,84 @@ func generateToken() (string, error) {
return base64.URLEncoding.EncodeToString(b), nil
}
func (h *TokenHandler) passwordValid(password string) bool {
if h.disableSessionAuth || h.apiPassword == "" {
func (s *Server) passwordValid(password string) bool {
if s.cfg.DisableSessionAuth || s.cfg.APIPassword == "" {
return true
}
return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1
}
// CreateTokenHandler creates a new session token
//
// @Summary Create session token
// @Description Returns a new session token for API access. Requires the API password when one is configured.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body TokenRequest true "Password and client type"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 429 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [post]
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
var req TokenRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
func (s *Server) registerToken(a apis) {
create := huma.Operation{
OperationID: "create-token",
Method: http.MethodPost,
Path: "/token",
Summary: "Create session token",
Description: "Returns a token for the other /api/v1 endpoints, valid for 24 hours. " +
"Requires the API password when the server has one. Limited to 10 requests per minute per client.",
Tags: []string{"auth"},
Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests},
Middlewares: huma.Middlewares{middleware.RateLimit(a.v1, 10, time.Minute)},
}
register(s, a, route{op: create, public: true},
func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) {
client := middleware.ClientFrom(ctx)
if !s.passwordValid(in.Body.Password) {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", client.IP), zap.String("user_agent", client.UserAgent))
return nil, huma.Error401Unauthorized("Invalid password")
}
if !h.passwordValid(req.Password) {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", c.RealIP()),
zap.String("user_agent", c.Request().UserAgent()))
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid password")
}
clientType := in.Body.ClientType
if clientType == "" {
clientType = "web"
}
token, err := generateToken()
if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to generate token")
}
session, err := s.q.CreateSession(ctx, repository.CreateSessionParams{
Token: token,
IpAddress: client.IP,
UserAgent: client.UserAgent,
ClientType: &clientType,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
})
if err != nil {
logging.GetLogger().Error("Failed to create session", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to create session")
}
return ok(TokenResponse{Token: session.Token, ExpiresAt: session.ExpiresAt.Time, ClientType: clientType}, nil)
})
if req.ClientType == "" {
req.ClientType = "web"
}
token, err := generateToken()
if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token")
}
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
Token: token,
IpAddress: c.RealIP(),
UserAgent: c.Request().UserAgent(),
ClientType: &req.ClientType,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
register(s, a, route{op: huma.Operation{
OperationID: "delete-token",
Method: http.MethodDelete,
Path: "/token",
Summary: "Invalidate session token",
Description: "Deletes the token used for this request.",
Tags: []string{"auth"},
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
if err := s.q.DeleteSession(ctx, middleware.Token(ctx)); err != nil {
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to invalidate token")
}
return ok(statusMessage{Status: "token invalidated"}, nil)
})
if err != nil {
logging.GetLogger().Error("Failed to create session", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create session")
}
clientType := req.ClientType
if session.ClientType != nil {
clientType = *session.ClientType
}
return c.JSON(http.StatusOK, TokenResponse{
Token: session.Token,
ExpiresAt: session.ExpiresAt.Time,
ClientType: clientType,
register(s, a, route{op: huma.Operation{
OperationID: "cleanup-tokens",
Method: http.MethodPost,
Path: "/token/cleanup",
Summary: "Remove expired tokens",
Description: "Deletes all expired session tokens.",
Tags: []string{"auth"},
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
if err := s.q.DeleteExpiredSessions(ctx); err != nil {
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to cleanup sessions")
}
return ok(statusMessage{Status: "cleanup complete"}, nil)
})
}
// DeleteTokenHandler invalidates a session token
//
// @Summary Invalidate session token
// @Description Deletes the current session token
// @Tags auth
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [delete]
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
if c.Request().Header.Get("Authorization") == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "Authorization header required")
}
token, ok := middleware.BearerToken(c)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid authorization format")
}
if err := h.q.DeleteSession(c.Request().Context(), token); err != nil {
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to invalidate token")
}
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
}
// CleanupExpiredSessionsHandler removes all expired sessions
//
// @Summary Cleanup expired sessions
// @Description Removes all expired session tokens from the database
// @Tags auth
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token/cleanup [post]
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil {
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to cleanup sessions")
}
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
}
-39
View File
@@ -1,39 +0,0 @@
package server
import (
"net/http"
"music-server/internal/backend"
"github.com/labstack/echo/v5"
)
type VersionHandler struct{}
func NewVersionHandler() *VersionHandler {
return &VersionHandler{}
}
// GetVersionHistory godoc
//
// @Summary Getting the version history of the backend
// @Description get version history
// @Tags version
// @Produce json
// @Success 200 {array} backend.VersionData
// @Router /version/history [get]
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, backend.GetVersionHistory())
}
// GetLatestVersion godoc
//
// @Summary Getting the latest version of the backend
// @Description get latest version info
// @Tags version
// @Produce json
// @Success 200 {object} backend.VersionData
// @Router /version [get]
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, backend.GetLatestVersion())
}
+34
View File
@@ -372,3 +372,37 @@ func (s *Store) HasSession(token string) bool {
_, ok := s.Sessions[token]
return ok
}
// Statistics queries return empty results; tests that need data use their own fakes.
func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {
return nil, s.Err
}
func (s *Store) GetLeastPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetLeastPlayedSoundtracksWithSongsRow, error) {
return nil, s.Err
}
func (s *Store) GetLastPlayedSoundtracks(context.Context, int32) ([]repository.GetLastPlayedSoundtracksRow, error) {
return nil, s.Err
}
func (s *Store) GetOldestPlayedSoundtracks(context.Context, int32) ([]repository.GetOldestPlayedSoundtracksRow, error) {
return nil, s.Err
}
func (s *Store) GetNeverPlayedSoundtracks(context.Context) ([]repository.GetNeverPlayedSoundtracksRow, error) {
return nil, s.Err
}
func (s *Store) GetMostPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetMostPlayedSongsWithSoundtrackRow, error) {
return nil, s.Err
}
func (s *Store) GetLeastPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetLeastPlayedSongsWithSoundtrackRow, error) {
return nil, s.Err
}
func (s *Store) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) {
return repository.GetStatisticsSummaryRow{}, s.Err
}