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