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

+39 -40
View File
@@ -1,61 +1,60 @@
package server
import (
"errors"
"net/http"
"os"
"path/filepath"
"time"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
"github.com/danielgtaylor/huma/v2"
)
// serveFile sends a file with support for Range requests and caching headers.
// contentType may be empty to detect it from the file extension.
func serveFile(c *echo.Context, f *os.File, contentType string) error {
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
// fileResponses documents an endpoint that returns a file with Range support.
func fileResponses(description string, contentTypes ...string) map[string]*huma.Response {
content := map[string]*huma.MediaType{}
for _, contentType := range contentTypes {
content[contentType] = &huma.MediaType{Schema: &huma.Schema{Type: "string", Format: "binary"}}
}
if info.IsDir() {
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
return map[string]*huma.Response{
"200": {Description: description, Content: content},
"206": {Description: "Part of the file, for a Range request", Content: content},
}
if contentType != "" {
c.Response().Header().Set(echo.HeaderContentType, contentType)
}
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
return nil
}
// serveSong streams a song. The write deadline is lifted because songs can take long to send to slow clients.
func serveSong(c *echo.Context, path string, err error) error {
if err != nil {
return songError(err)
}
// streamFile opens path and returns a response that sends it with Range support.
// contentType may be empty to detect it from the file extension. Audio has no write
// deadline because songs can take long to send to slow clients.
func streamFile(path, contentType string, audio bool) (*huma.StreamResponse, error) {
f, err := os.Open(path)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
return nil, huma.Error404NotFound("Not Found")
}
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
return serveFile(c, f, "audio/mpeg")
return streamOpenFile(f, contentType, audio)
}
// songError maps backend errors to HTTP errors.
func songError(err error) error {
switch {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
return echo.NewHTTPError(http.StatusNotFound, err.Error())
case errors.Is(err, backend.ErrInvalidName):
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
case errors.Is(err, backend.ErrUpstream):
return echo.NewHTTPError(http.StatusBadGateway, err.Error())
default:
logging.GetLogger().Error("Request failed", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error")
func streamOpenFile(f *os.File, contentType string, audio bool) (*huma.StreamResponse, error) {
info, err := f.Stat()
if err != nil || info.IsDir() {
f.Close()
return nil, huma.Error404NotFound("Not Found")
}
return &huma.StreamResponse{Body: func(ctx huma.Context) {
defer f.Close()
c := echoContext(ctx)
if contentType != "" {
c.Response().Header().Set("Content-Type", contentType)
}
if audio {
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
}
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
}}, nil
}
// streamSong streams the song at path, or returns err mapped to an API error.
func streamSong(path string, err error) (*huma.StreamResponse, error) {
if err != nil {
return nil, apiError(err)
}
return streamFile(path, "audio/mpeg", true)
}