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

+6 -12
View File
@@ -1,16 +1,10 @@
package middleware
import (
"github.com/labstack/echo/v5"
)
import "github.com/danielgtaylor/huma/v2"
// DeprecationMiddleware adds deprecation warning to responses
// for old endpoints that are being phased out in favor of /api/v1/*
func DeprecationMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// Add deprecation warning header
c.Response().Header().Add("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
c.Response().Header().Add("Deprecation", "true")
return next(c)
}
// Deprecation marks responses from legacy routes as deprecated in favour of /api/v1.
func Deprecation(ctx huma.Context, next func(huma.Context)) {
ctx.AppendHeader("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
ctx.AppendHeader("Deprecation", "true")
next(ctx)
}
+14 -1
View File
@@ -1,2 +1,15 @@
// Package middleware provides Echo middleware for the MusicServer application.
// Package middleware provides Huma middleware for the MusicServer API.
package middleware
import (
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
)
// Middleware is a Huma middleware function.
type Middleware = func(ctx huma.Context, next func(huma.Context))
// clientIP returns the client IP as resolved by Echo (respecting trusted proxies).
func clientIP(ctx huma.Context) string {
return humaecho.Unwrap(ctx).RealIP()
}
+11 -10
View File
@@ -6,24 +6,25 @@ import (
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"github.com/danielgtaylor/huma/v2"
echoMiddleware "github.com/labstack/echo/v5/middleware"
"go.uber.org/zap"
)
// RateLimit allows each client IP limit requests per window, with bursts up to limit.
func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc {
config := echoMiddleware.DefaultRateLimiterConfig
config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
func RateLimit(api huma.API, limit int, window time.Duration) Middleware {
store := echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
Rate: float64(limit) / window.Seconds(),
Burst: limit,
ExpiresIn: window,
})
config.DenyHandler = func(c *echo.Context, identifier string, err error) error {
logging.GetLogger().Warn("Rate limit exceeded",
zap.String("ip", identifier),
zap.String("path", c.Request().URL.Path))
return echo.NewHTTPError(http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.")
return func(ctx huma.Context, next func(huma.Context)) {
ip := clientIP(ctx)
if allowed, _ := store.Allow(ip); !allowed {
logging.GetLogger().Warn("Rate limit exceeded", zap.String("ip", ip), zap.String("path", ctx.URL().Path))
_ = huma.WriteErr(api, ctx, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.")
return
}
next(ctx)
}
return echoMiddleware.RateLimiterWithConfig(config)
}
-22
View File
@@ -1,22 +0,0 @@
package middleware
import (
"net/http"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
)
// SyncCheck blocks requests with HTTP 423 Locked while a sync is in progress.
func SyncCheck(isSyncing func() bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if isSyncing() {
logging.GetLogger().Info("Syncing is in progress - request blocked")
return echo.NewHTTPError(http.StatusLocked, "Syncing is in progress")
}
return next(c)
}
}
}
+68 -41
View File
@@ -9,54 +9,81 @@ import (
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"github.com/danielgtaylor/huma/v2"
"go.uber.org/zap"
)
type tokenKey struct{}
// Token returns the session token of an authenticated request.
func Token(ctx context.Context) string {
token, _ := ctx.Value(tokenKey{}).(string)
return token
}
// BearerToken extracts the token from an "Authorization: Bearer <token>" header.
func BearerToken(c *echo.Context) (string, bool) {
token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
func BearerToken(header string) (string, bool) {
token, ok := strings.CutPrefix(header, "Bearer ")
return token, ok && token != "" && !strings.Contains(token, " ")
}
// TokenAuth validates session tokens. When disabled is true all requests are let through.
func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if disabled {
return next(c)
}
if c.Request().Header.Get("Authorization") == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "Authorization header required")
}
token, ok := BearerToken(c)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid authorization format. Use: Bearer <token>")
}
ctx := c.Request().Context()
session, err := q.GetSession(ctx, token)
if err != nil {
logging.GetLogger().Warn("Invalid token attempt",
zap.String("ip", c.RealIP()),
zap.String("error", err.Error()),
)
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired token")
}
if time.Now().After(session.ExpiresAt.Time) {
// Clean up in the background with a context that outlives the request.
go func(ctx context.Context) {
if err := q.DeleteSession(ctx, token); err != nil {
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
}
}(context.WithoutCancel(ctx))
return echo.NewHTTPError(http.StatusUnauthorized, "Token expired")
}
c.Set("session", session)
return next(c)
// TokenAuth validates the session token for operations that declare a security requirement.
// When disabled is true all requests are let through.
func TokenAuth(api huma.API, q repository.Querier, disabled bool) Middleware {
return func(ctx huma.Context, next func(huma.Context)) {
if disabled || len(ctx.Operation().Security) == 0 {
next(ctx)
return
}
header := ctx.Header("Authorization")
if header == "" {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Authorization header required")
return
}
token, ok := BearerToken(header)
if !ok {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid authorization format. Use: Bearer <token>")
return
}
session, err := q.GetSession(ctx.Context(), token)
if err != nil {
logging.GetLogger().Warn("Invalid token attempt", zap.String("ip", clientIP(ctx)), zap.Error(err))
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid or expired token")
return
}
if time.Now().After(session.ExpiresAt.Time) {
// Clean up in the background with a context that outlives the request.
go func(ctx context.Context) {
if err := q.DeleteSession(ctx, token); err != nil {
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
}
}(context.WithoutCancel(ctx.Context()))
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Token expired")
return
}
next(huma.WithValue(ctx, tokenKey{}, token))
}
}
type clientKey struct{}
// Client holds request details that handlers need but that are not part of the API input.
type Client struct {
IP string
UserAgent string
}
// ClientInfo stores the client's IP and user agent in the request context.
func ClientInfo(ctx huma.Context, next func(huma.Context)) {
next(huma.WithValue(ctx, clientKey{}, Client{IP: clientIP(ctx), UserAgent: ctx.Header("User-Agent")}))
}
// ClientFrom returns the client details stored by ClientInfo.
func ClientFrom(ctx context.Context) Client {
client, _ := ctx.Value(clientKey{}).(Client)
return client
}