diff --git a/pkg/routes/api/v1/humaapi/crud.go b/pkg/routes/api/v1/humaapi/crud.go index c7822fc71..6812b95ae 100644 --- a/pkg/routes/api/v1/humaapi/crud.go +++ b/pkg/routes/api/v1/humaapi/crud.go @@ -2,14 +2,47 @@ package humaapi import ( "context" + "errors" + "fmt" "net/http" "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/web" "code.vikunja.io/api/pkg/web/handler" "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" ) +// translateError converts errors returned by the shared Do* pipeline (which +// originate from Echo handlers and Vikunja domain types) into Huma +// StatusErrors so Huma emits the right HTTP status + Vikunja-shaped body. +// Any unrecognised error is returned as-is (Huma will wrap it as 500). +func translateError(err error) error { + if err == nil { + return nil + } + // Vikunja domain errors — keep the {code, message} shape but lift the + // HTTP status. + var proc web.HTTPErrorProcessor + if errors.As(err, &proc) { + he := proc.HTTPError() + status := he.HTTPCode + if status == 0 { + status = http.StatusInternalServerError + } + ve := &vikunjaError{StatusCode: status, Code: he.Code, Message: he.Message} + return ve + } + // Forbidden / NotFound etc. raised via echo.NewHTTPError. + var hErr *echo.HTTPError + if errors.As(err, &hErr) { + msg := fmt.Sprint(hErr.Message) + return &vikunjaError{StatusCode: hErr.Code, Message: msg} + } + return err +} + // SingleID is the common path shape for /resource/{id} endpoints. type SingleID struct { ID int64 `path:"id" doc:"Resource ID"` @@ -96,7 +129,7 @@ func Register[T handler.CObject](api huma.API, cfg Config[T, SingleID]) { return nil, huma.Error401Unauthorized(err.Error()) } if err := handler.DoCreate(ctx, in.Body, a); err != nil { - return nil, err + return nil, translateError(err) } return &bodyOutput[T]{Body: in.Body}, nil }) @@ -116,7 +149,7 @@ func Register[T handler.CObject](api huma.API, cfg Config[T, SingleID]) { obj := cfg.New() cfg.ApplyPath(obj, in.SingleID) if _, err := handler.DoReadOne(ctx, obj, a); err != nil { - return nil, err + return nil, translateError(err) } return &bodyOutput[T]{Body: obj}, nil }) @@ -136,7 +169,7 @@ func Register[T handler.CObject](api huma.API, cfg Config[T, SingleID]) { obj := cfg.New() result, _, _, err := handler.DoReadAll(ctx, obj, a, in.Search, in.Page, in.PerPage) if err != nil { - return nil, err + return nil, translateError(err) } // Best-effort cast; ReadAll returns interface{}. For the spike // we assume []T. Resources returning a different list item type @@ -163,7 +196,7 @@ func Register[T handler.CObject](api huma.API, cfg Config[T, SingleID]) { } cfg.ApplyPath(in.Body, in.SingleID) if err := handler.DoUpdate(ctx, in.Body, a); err != nil { - return nil, err + return nil, translateError(err) } return &bodyOutput[T]{Body: in.Body}, nil }) @@ -183,7 +216,7 @@ func Register[T handler.CObject](api huma.API, cfg Config[T, SingleID]) { obj := cfg.New() cfg.ApplyPath(obj, in.SingleID) if err := handler.DoDelete(ctx, obj, a); err != nil { - return nil, err + return nil, translateError(err) } return &bodyOutput[deleteMessage]{Body: deleteMessage{Message: "Successfully deleted."}}, nil }) diff --git a/pkg/routes/api/v1/humaapi/errors.go b/pkg/routes/api/v1/humaapi/errors.go index 21c104401..ffc45b823 100644 --- a/pkg/routes/api/v1/humaapi/errors.go +++ b/pkg/routes/api/v1/humaapi/errors.go @@ -19,7 +19,7 @@ func (e *vikunjaError) GetStatus() int { return e.StatusCode } // (`{"message": "..."}` for plain errors; `{"code": X, "message": "..."}` when // a domain code is supplied). Registered as huma.NewError so every Huma // handler's error return routes through here. -func NewVikunjaError(status int, msg string, errs ...error) huma.StatusError { +func NewVikunjaError(status int, msg string, _ ...error) huma.StatusError { return &vikunjaError{StatusCode: status, Message: msg} } diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index f9b9ce7f2..92ebb0079 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -55,6 +55,7 @@ import ( "context" "log/slog" "net" + "net/http" "strings" "time" @@ -313,9 +314,9 @@ var unauthenticatedAPIPaths = map[string]bool{ "/api/v1/docs.json": true, "/api/v1/docs": true, "/api/v1/docs/redoc.standalone.js": true, - "/api/v1/openapi": true, - "/api/v1/openapi.json": true, - "/api/v1/openapi.yaml": true, + "/api/v1/oas3/openapi": true, + "/api/v1/oas3/openapi.json": true, + "/api/v1/oas3/openapi.yaml": true, "/api/v1/metrics": true, "/api/v1/oauth/token": true, } @@ -425,12 +426,47 @@ func registerAPIRoutes(e *echo.Echo, a *echo.Group) { // ===== Huma OAS 3.1 spike wiring ===== // Registers the Vikunja error formatter globally and mounts a parallel // Huma API on the same /api/v1 group, so the legacy routes keep working - // and the new registrations produce the 3.1 spec at /api/v1/openapi.json. + // and the new registrations produce the 3.1 spec at /api/v1/oas3/openapi.json. humaapi.Install() humaConfig := huma.DefaultConfig("Vikunja API (OAS 3.1 spike)", "0.0.1") - humaConfig.OpenAPIPath = "/openapi" - humaAPI := humaecho5.NewWithGroup(e, a, humaConfig) + // Serve the spec ourselves on the unauth group below; disabling Huma's + // built-in registration prevents the JWT middleware from covering it. + humaConfig.OpenAPIPath = "" + // Disable Huma's built-in docs UI — Vikunja already serves its own at + // /api/v1/docs (Redoc, legacy swagger 2.0). + humaConfig.DocsPath = "" + // Match the legacy handler's forgiving body contract: all fields optional + // unless explicitly tagged `required`. Huma defaults to strict-required + // for every non-omitempty field, which would reject PUT /labels bodies + // that omit derived timestamps / created_by. The legacy govalidator tags + // on the model still enforce the real rules during Create/Update. + humaConfig.FieldsOptionalByDefault = true + // Echo v5 panics on duplicate (method, path) registrations. The legacy + // Label routes live at /api/v1/labels, so Huma is mounted under a + // sub-group (/api/v1/oas3) to avoid the collision while still exercising + // the adapter + JWT middleware inherited from `a`. Full migration will + // reclaim the real paths once the legacy handlers are deleted. + humaGroup := a.Group("/oas3") + humaAPI := humaecho5.NewWithGroup(e, humaGroup, humaConfig) humaapi.RegisterLabelRoutes(humaAPI) + // Expose the spec on the unauthenticated group so the frontend and tools + // can fetch it without a bearer token (matches the legacy /docs.json + // treatment). Echo routes registered on `n` before JWT middleware was + // attached to `a` escape the auth requirement. + n.GET("/oas3/openapi.json", func(c *echo.Context) error { + body, err := humaAPI.OpenAPI().MarshalJSON() + if err != nil { + return err + } + return c.Blob(http.StatusOK, "application/json", body) + }) + n.GET("/oas3/openapi.yaml", func(c *echo.Context) error { + body, err := humaAPI.OpenAPI().YAML() + if err != nil { + return err + } + return c.Blob(http.StatusOK, "application/yaml", body) + }) a.GET("/token/test", apiv1.TestToken) a.POST("/token/test", apiv1.CheckToken)