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
@@ -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))
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user