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

+18 -129
View File
@@ -2,9 +2,7 @@ package server
import (
"net/http"
"time"
_ "music-server/cmd/docs" // registers the OpenAPI spec
"music-server/cmd/web"
"music-server/internal/logging"
"music-server/internal/server/middleware"
@@ -12,51 +10,9 @@ import (
"github.com/a-h/templ"
"github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware"
echoSwagger "github.com/swaggo/echo-swagger/v2"
"github.com/swaggo/swag"
"go.uber.org/zap"
)
// @title MusicServer API
// @version 1.0
// @description API for the MusicServer application
// @termsOfService http://sanplex.xyz/terms/
// @contact.name Sebastian Olsson
// @contact.email zarnor91@gmail.com
// @license.name MIT
// @license.url http://opensource.org/licenses/MIT
// @BasePath /
// handlers holds all HTTP handlers.
type handlers struct {
health *HealthHandler
version *VersionHandler
character *CharacterHandler
download *DownloadHandler
sync *SyncHandler
music *MusicHandler
library *LibraryHandler
statistics *StatisticsHandler
token *TokenHandler
}
func (s *Server) handlers() handlers {
return handlers{
health: NewHealthHandler(s.db),
version: NewVersionHandler(),
character: NewCharacterHandler(s.characters),
download: NewDownloadHandler(s.releases),
sync: NewSyncHandler(s.sync),
music: NewMusicHandler(s.music),
library: NewLibraryHandler(s.library),
statistics: NewStatisticsHandler(s.stats),
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
}
}
func (s *Server) RegisterRoutes() http.Handler {
e := echo.New()
e.HTTPErrorHandler = httpErrorHandler
@@ -78,102 +34,35 @@ func (s *Server) RegisterRoutes() http.Handler {
MaxAge: 300,
}))
e.GET("/openapi", serveOpenAPI)
e.GET("/swagger/*", echoSwagger.WrapHandler)
// The API: /api/v1 is documented at /docs and /openapi.json; legacy routes are hidden.
a := newAPIs(e)
a.v1.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a.v1, s.q, s.cfg.DisableSessionAuth))
a.legacy.UseMiddleware(middleware.ClientInfo, middleware.Deprecation)
s.registerToken(a)
s.registerMisc(a)
s.registerSync(a)
s.registerMusic(a)
s.registerLibrary(a)
s.registerStatistics(a)
// Older links to the spec and docs.
e.GET("/openapi", redirect("/openapi.json"))
e.GET("/swagger/*", redirect("/docs"))
// Search page and frontend.
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
e.Static("/", s.cfg.FrontendPath)
h := s.handlers()
syncLock := middleware.SyncCheck(s.sync.IsSyncing)
// Legacy endpoints (deprecated - use /api/v1/ instead)
legacy := e.Group("", middleware.DeprecationMiddleware)
registerCommon(legacy, h, syncLock)
legacy.GET("/health", h.health.HealthCheck)
legacy.GET("/characters", h.character.GetCharacterList)
// API v1
apiV1 := e.Group("/api/v1")
tokenGroup := apiV1.Group("/token")
tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute))
tokenGroup.DELETE("", h.token.DeleteTokenHandler)
protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth))
protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler)
registerCommon(protected, h, syncLock)
protected.GET("/character/list", h.character.GetCharacterList)
protected.GET("/soundtracks", h.library.GetSoundtracks)
protected.GET("/soundtracks/:uuid", h.library.GetSoundtrack)
protected.GET("/songs/:uuid", h.library.GetSong)
protected.GET("/songs/:uuid/file", h.library.GetSongFile, syncLock)
stats := protected.Group("/statistics")
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks)
stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks)
stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks)
stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs)
stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs)
stats.GET("/summary", h.statistics.GetStatisticsSummary)
for _, r := range e.Router().Routes() {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
}
return e
}
// registerCommon registers the routes shared by the legacy and the v1 API.
// syncLock is applied to routes that must not run during a sync.
func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) {
g.GET("/version", h.version.GetLatestVersion)
g.GET("/version/history", h.version.GetVersionHistory)
g.GET("/character", h.character.GetCharacter)
g.GET("/download", h.download.CheckLatest)
g.GET("/download/list", h.download.ListAssetsOfLatest)
g.GET("/download/windows", h.download.DownloadLatestWindows)
g.GET("/download/linux", h.download.DownloadLatestLinux)
g.GET("/sync/progress", h.sync.SyncProgress)
for _, path := range []string{"/sync", "/sync/new", "/sync/quick"} {
g.POST(path, h.sync.SyncSoundtracksNewOnlyChanges, syncLock)
func redirect(to string) echo.HandlerFunc {
return func(c *echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, to)
}
for _, path := range []string{"/sync/full", "/sync/new/full"} {
g.POST(path, h.sync.SyncSoundtracksNewFull, syncLock)
}
g.POST("/sync/reset", h.sync.ResetDB, syncLock)
music := g.Group("/music", syncLock)
music.GET("", h.music.GetSong)
music.GET("/soundTest", h.music.GetSoundCheckSong)
music.GET("/reset", h.music.ResetMusic)
music.GET("/rand", h.music.GetRandomSong)
music.GET("/rand/low", h.music.GetRandomSongLowChance)
music.GET("/rand/classic", h.music.GetRandomSongClassic)
music.GET("/next", h.music.GetNextSong)
music.GET("/previous", h.music.GetPreviousSong)
music.GET("/all", h.music.GetAllSoundtracksRandom)
music.GET("/all/order", h.music.GetAllSoundtracks)
music.GET("/all/random", h.music.GetAllSoundtracksRandom)
music.PUT("/played", h.music.PutPlayed)
music.GET("/addQue", h.music.AddLatestToQue)
music.GET("/addPlayed", h.music.AddLatestPlayed)
// Info and list are read-only and stay available during a sync.
g.GET("/music/info", h.music.GetSongInfo)
g.GET("/music/list", h.music.GetPlayedSongs)
}
// serveOpenAPI serves the embedded OpenAPI spec.
func serveOpenAPI(c *echo.Context) error {
doc, err := swag.ReadDoc()
if err != nil {
return err
}
return c.Blob(http.StatusOK, echo.MIMEApplicationJSON, []byte(doc))
}