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

+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))
})
}