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

+165
View File
@@ -0,0 +1,165 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
const (
apiPrefix = "/api/v1"
bearerAuthName = "bearer"
)
var bearerAuth = []map[string][]string{{bearerAuthName: {}}}
// APIError is the body of every error response.
type APIError struct {
status int
Message string `json:"error" example:"Not Found" doc:"What went wrong"`
}
func (e *APIError) Error() string { return e.Message }
func (e *APIError) GetStatus() int { return e.status }
func init() {
// The API always returns [] for empty lists, never null.
huma.DefaultArrayNullable = false
// Huma creates all its errors (validation, 404, ...) through NewError.
huma.NewError = func(status int, msg string, errs ...error) huma.StatusError {
var details []string
for _, err := range errs {
if err != nil {
details = append(details, err.Error())
}
}
if len(details) > 0 {
msg += ": " + strings.Join(details, "; ")
}
return &APIError{status: status, Message: msg}
}
}
// apis holds the documented /api/v1 API and the hidden, deprecated legacy routes.
type apis struct {
v1 huma.API
legacy huma.API
}
func newAPIs(e *echo.Echo) apis {
config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version)
// No $schema fields or Link headers in responses.
config.CreateHooks = nil
config.DocsRenderer = huma.DocsRendererScalar
config.Info.Description = "API for the MusicServer application.\n\n" +
"Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer <token>`.\n\n" +
"The same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token " +
"and are not documented here. They will be removed."
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"},
}
legacyConfig := huma.DefaultConfig("MusicServer legacy API", backend.GetLatestVersion().Version)
legacyConfig.CreateHooks = nil
legacyConfig.OpenAPIPath = ""
legacyConfig.DocsPath = ""
legacyConfig.SchemasPath = ""
return apis{v1: humaecho.New(e, config), legacy: humaecho.New(e, legacyConfig)}
}
// route describes an endpoint registered on /api/v1 and, optionally, as a legacy route.
type route struct {
op huma.Operation
// public endpoints need no token.
public bool
// legacyPath is the deprecated path without /api/v1 (empty: no legacy route).
legacyPath string
// locked endpoints answer 423 while a sync is running.
locked bool
}
// register adds the route's operation to the v1 API and, if it has one, its hidden legacy route.
func register[I, O any](s *Server, a apis, r route, handler func(context.Context, *I) (*O, error)) {
op := r.op
op.Path = apiPrefix + r.op.Path
if !r.public {
op.Security = bearerAuth
op.Errors = append(op.Errors, http.StatusUnauthorized)
}
if r.locked {
op.Middlewares = append(op.Middlewares, s.syncLock(a.v1))
op.Errors = append(op.Errors, http.StatusLocked)
}
huma.Register(a.v1, op, handler)
if r.legacyPath == "" {
return
}
legacy := r.op
legacy.OperationID = "legacy-" + r.op.OperationID
legacy.Path = r.legacyPath
legacy.Hidden = true
legacy.Deprecated = true
if r.locked {
legacy.Middlewares = append(legacy.Middlewares, s.syncLock(a.legacy))
}
huma.Register(a.legacy, legacy, handler)
}
// syncLock answers 423 Locked while a sync is running.
func (s *Server) syncLock(api huma.API) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
if s.sync.IsSyncing() {
logging.GetLogger().Info("Syncing is in progress - request blocked")
_ = huma.WriteErr(api, ctx, http.StatusLocked, "Syncing is in progress")
return
}
next(ctx)
}
}
// apiError maps backend errors to API errors.
func apiError(err error) error {
switch {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
return huma.Error404NotFound(err.Error())
case errors.Is(err, backend.ErrInvalidName):
return huma.Error400BadRequest(err.Error())
case errors.Is(err, backend.ErrUpstream):
return huma.Error502BadGateway(err.Error())
default:
logging.GetLogger().Error("Request failed", zap.Error(err))
return huma.Error500InternalServerError("Internal Server Error")
}
}
// echoContext returns the Echo context behind a Huma context.
func echoContext(ctx huma.Context) *echo.Context {
return humaecho.Unwrap(ctx)
}
// noBody is the output of operations that only return a status.
type noBody struct{}
// jsonBody wraps a JSON response body.
type jsonBody[T any] struct {
Body T
}
func ok[T any](body T, err error) (*jsonBody[T], error) {
if err != nil {
return nil, apiError(err)
}
return &jsonBody[T]{Body: body}, nil
}