fix(spike): mount Huma under /oas3 and translate Vikunja errors
Three follow-ups to Task E2 needed to make the routes functional end to end:
- Echo v5 panics on duplicate (method, path) registrations, so the
Huma-backed label routes live under /api/v1/oas3/labels for the spike.
The legacy /api/v1/labels endpoints remain unchanged.
- Huma's built-in /openapi.{json,yaml,docs} routes are disabled. The spec
is re-exposed via a handler on the unauth group so clients and tooling
can fetch it without a bearer token, matching the /docs.json treatment.
- Errors returned from the shared handler.Do* pipeline (echo.HTTPError,
web.HTTPErrorProcessor) are translated into Vikunja-shaped
huma.StatusErrors, preserving the legacy {code, message} body contract
instead of Huma's default "unexpected error occurred" wrap.
Also sets humaConfig.FieldsOptionalByDefault=true so PUT/POST bodies
don't need to include derived fields like created/updated/created_by.
This commit is contained in:
parent
aef4cc3f8a
commit
898ca26627
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue