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