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

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