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