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