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