From 24188480c4b0c92caa4d19b577f4558e2b50cef4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sat, 6 Jun 2026 23:00:32 +0200 Subject: [PATCH] feat(api/v2): return 422 with invalid_fields for validation errors --- pkg/routes/api/v2/errors.go | 46 +++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/pkg/routes/api/v2/errors.go b/pkg/routes/api/v2/errors.go index a11ca25f7..3292b2e2b 100644 --- a/pkg/routes/api/v2/errors.go +++ b/pkg/routes/api/v2/errors.go @@ -20,8 +20,10 @@ import ( "context" "errors" "net/http" + "strings" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/web" @@ -43,21 +45,6 @@ func authFromCtx(ctx context.Context) (web.Auth, error) { return a, nil } -// httpCodeGetter is satisfied by validation errors that carry an HTTP status -// without the HTTPErrorProcessor method — notably models.ValidationHTTPError, -// returned by InvalidFieldError. v1's error handler reads GetHTTPCode() off these; -// without the same branch here they'd fall through to a 500. -type httpCodeGetter interface { - GetHTTPCode() int -} - -// domainCodeGetter exposes Vikunja's numeric domain error code on errors that -// are not HTTPErrorProcessors (again, ValidationHTTPError) so v2 can keep the v1 -// `code` body contract. -type domainCodeGetter interface { - GetCode() int -} - // translateDomainError maps a Vikunja domain error (web.HTTPErrorProcessor) // onto Huma's status-error type so the response carries the right code // and an RFC 9457 body. Errors without HTTP semantics fall through, which @@ -82,20 +69,35 @@ func translateDomainError(err error) error { } return se } - var cg httpCodeGetter - if errors.As(err, &cg) { - se := huma.NewError(cg.GetHTTPCode(), err.Error()) + // v2 maps validation failures to 422 (not v1's 412) so a govalidator failure + // looks identical to Huma's own schema validation. ValidationHTTPError isn't an + // HTTPErrorProcessor (the embedded field shadows the method), so it lands here. + var ve models.ValidationHTTPError + if errors.As(err, &ve) { + se := huma.NewError(http.StatusUnprocessableEntity, ve.Error(), invalidFieldDetails(ve.InvalidFields)...) if vm, ok := se.(*vikunjaErrorModel); ok { - var dc domainCodeGetter - if errors.As(err, &dc) { - vm.Code = dc.GetCode() - } + vm.Code = ve.GetCode() } return se } return err } +// invalidFieldDetails turns ValidationHTTPError's invalid_fields into RFC 9457 +// error details. Entries come in two shapes — govalidator's "field: message" and +// model call sites' bare field names — and both must yield a Location. +func invalidFieldDetails(fields []string) []error { + details := make([]error, 0, len(fields)) + for _, f := range fields { + name, msg, ok := strings.Cut(f, ": ") + if !ok { + msg = "Invalid data" + } + details = append(details, &huma.ErrorDetail{Location: "body." + name, Message: msg}) + } + return details +} + // vikunjaErrorModel extends Huma's RFC 9457 body with Vikunja's numeric // domain error code, preserving the v1 error-code contract on v2. Wired in // as the global error type via the huma.NewError override in init().