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 `.\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 }