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