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

+79 -113
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
@@ -11,8 +12,8 @@ import (
"music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/danielgtaylor/huma/v2"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
@@ -20,27 +21,23 @@ const tokenLifetime = 24 * time.Hour
// TokenRequest represents a request to generate a new token
type TokenRequest struct {
Password string `json:"password"` // API password, required when API_PASSWORD is set
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
Password string `json:"password,omitempty" doc:"API password, required when the server has one configured"`
ClientType string `json:"client_type,omitempty" doc:"Kind of client, for example web, godot or api" default:"web"`
}
// TokenResponse represents the response with a new token
type TokenResponse struct {
Token string `json:"token"`
Token string `json:"token" doc:"Send as Authorization: Bearer <token>"`
ExpiresAt time.Time `json:"expires_at"`
ClientType string `json:"client_type"`
}
// TokenHandler creates and removes session tokens.
type TokenHandler struct {
q repository.Querier
apiPassword string
disableSessionAuth bool
type tokenInput struct {
Body TokenRequest `required:"false"`
}
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password.
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler {
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
type statusMessage struct {
Status string `json:"status"`
}
func generateToken() (string, error) {
@@ -51,115 +48,84 @@ func generateToken() (string, error) {
return base64.URLEncoding.EncodeToString(b), nil
}
func (h *TokenHandler) passwordValid(password string) bool {
if h.disableSessionAuth || h.apiPassword == "" {
func (s *Server) passwordValid(password string) bool {
if s.cfg.DisableSessionAuth || s.cfg.APIPassword == "" {
return true
}
return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1
}
// CreateTokenHandler creates a new session token
//
// @Summary Create session token
// @Description Returns a new session token for API access. Requires the API password when one is configured.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body TokenRequest true "Password and client type"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 429 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [post]
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
var req TokenRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
func (s *Server) registerToken(a apis) {
create := huma.Operation{
OperationID: "create-token",
Method: http.MethodPost,
Path: "/token",
Summary: "Create session token",
Description: "Returns a token for the other /api/v1 endpoints, valid for 24 hours. " +
"Requires the API password when the server has one. Limited to 10 requests per minute per client.",
Tags: []string{"auth"},
Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests},
Middlewares: huma.Middlewares{middleware.RateLimit(a.v1, 10, time.Minute)},
}
register(s, a, route{op: create, public: true},
func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) {
client := middleware.ClientFrom(ctx)
if !s.passwordValid(in.Body.Password) {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", client.IP), zap.String("user_agent", client.UserAgent))
return nil, huma.Error401Unauthorized("Invalid password")
}
if !h.passwordValid(req.Password) {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", c.RealIP()),
zap.String("user_agent", c.Request().UserAgent()))
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid password")
}
clientType := in.Body.ClientType
if clientType == "" {
clientType = "web"
}
token, err := generateToken()
if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to generate token")
}
session, err := s.q.CreateSession(ctx, repository.CreateSessionParams{
Token: token,
IpAddress: client.IP,
UserAgent: client.UserAgent,
ClientType: &clientType,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
})
if err != nil {
logging.GetLogger().Error("Failed to create session", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to create session")
}
return ok(TokenResponse{Token: session.Token, ExpiresAt: session.ExpiresAt.Time, ClientType: clientType}, nil)
})
if req.ClientType == "" {
req.ClientType = "web"
}
token, err := generateToken()
if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token")
}
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
Token: token,
IpAddress: c.RealIP(),
UserAgent: c.Request().UserAgent(),
ClientType: &req.ClientType,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
register(s, a, route{op: huma.Operation{
OperationID: "delete-token",
Method: http.MethodDelete,
Path: "/token",
Summary: "Invalidate session token",
Description: "Deletes the token used for this request.",
Tags: []string{"auth"},
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
if err := s.q.DeleteSession(ctx, middleware.Token(ctx)); err != nil {
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to invalidate token")
}
return ok(statusMessage{Status: "token invalidated"}, nil)
})
if err != nil {
logging.GetLogger().Error("Failed to create session", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create session")
}
clientType := req.ClientType
if session.ClientType != nil {
clientType = *session.ClientType
}
return c.JSON(http.StatusOK, TokenResponse{
Token: session.Token,
ExpiresAt: session.ExpiresAt.Time,
ClientType: clientType,
register(s, a, route{op: huma.Operation{
OperationID: "cleanup-tokens",
Method: http.MethodPost,
Path: "/token/cleanup",
Summary: "Remove expired tokens",
Description: "Deletes all expired session tokens.",
Tags: []string{"auth"},
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
if err := s.q.DeleteExpiredSessions(ctx); err != nil {
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to cleanup sessions")
}
return ok(statusMessage{Status: "cleanup complete"}, nil)
})
}
// DeleteTokenHandler invalidates a session token
//
// @Summary Invalidate session token
// @Description Deletes the current session token
// @Tags auth
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [delete]
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
if c.Request().Header.Get("Authorization") == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "Authorization header required")
}
token, ok := middleware.BearerToken(c)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid authorization format")
}
if err := h.q.DeleteSession(c.Request().Context(), token); err != nil {
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to invalidate token")
}
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
}
// CleanupExpiredSessionsHandler removes all expired sessions
//
// @Summary Cleanup expired sessions
// @Description Removes all expired session tokens from the database
// @Tags auth
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token/cleanup [post]
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil {
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to cleanup sessions")
}
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
}