package server import ( "context" "errors" "net/http" "strings" "music-server/internal/backend" "music-server/internal/logging" "music-server/internal/server/middleware" "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} } } func newAPI(e *echo.Echo) huma.API { 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" + "Every client has its own play queue. The music endpoints return its ID in the `X-Queue-Id` header; " + "send it back in the same header to keep using that queue. Without it, or when the queue has ended " + "(after a reset or a sync), a new queue is started." config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"}, } return humaecho.New(e, config) } // route describes an endpoint registered on /api/v1. type route struct { op huma.Operation // public endpoints need no token. public bool // locked endpoints answer 423 while a sync is running. locked bool // queue endpoints work on the client's play queue, see middleware.Queue. queue bool } var queueHeaderParam = &huma.Param{ Name: middleware.QueueHeader, In: "header", Description: "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"}, } // register adds the route's operation to the API. func register[I, O any](s *Server, a huma.API, 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)) op.Errors = append(op.Errors, http.StatusLocked) } if r.queue { op.Middlewares = append(op.Middlewares, middleware.Queue(a, s.music.ResolveQueue)) op.Parameters = append(op.Parameters, queueHeaderParam) } huma.Register(a, op, handler) if r.queue { documentQueueHeader(a, op) } } // documentQueueHeader adds the X-Queue-Id header to the operation's success responses. func documentQueueHeader(a huma.API, op huma.Operation) { path := a.OpenAPI().Paths[op.Path] registered := map[string]*huma.Operation{ http.MethodGet: path.Get, http.MethodPut: path.Put, http.MethodPost: path.Post, http.MethodDelete: path.Delete, }[op.Method] for status, resp := range registered.Responses { if !strings.HasPrefix(status, "2") { continue } if resp.Headers == nil { resp.Headers = map[string]*huma.Param{} } if _, ok := resp.Headers[middleware.QueueHeader]; !ok { resp.Headers[middleware.QueueHeader] = &huma.Param{ Description: "ID of the client's play queue. Send it with the following requests.", Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"}, } } } } // 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), errors.Is(err, backend.ErrInvalidImage): return huma.Error400BadRequest(err.Error()) case errors.Is(err, backend.ErrCoversDisabled), errors.Is(err, backend.ErrUploadsUnavailable): return huma.Error503ServiceUnavailable(err.Error()) case errors.Is(err, backend.ErrConflict): return huma.Error409Conflict(err.Error()) case errors.Is(err, backend.ErrSyncing): return huma.Error423Locked(err.Error()) case errors.Is(err, backend.ErrTooLarge): return huma.Error413RequestEntityTooLarge(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 }