Generate the OpenAPI spec with Huma instead of swag
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:
1 parent
0ec2425f78
commit
973ebf224b
37 files changed
+1245
-5967
No files matched your search
+134
-218
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user