Compare commits

...

17 Commits

Author SHA1 Message Date
kolaente 692249fa04 fix(spike): remove broken TestDoCreate_HappyPath
The handler package has no TestMain to initialize DB/fixtures, so the
test panics on nil pointer deref when run via mage test:feature (which
walks every package) even though it was hidden when running only
mage test:web. DoCreate is already covered end-to-end by
TestHumaLabel_Create_ReadOne_via_OAS31Route in pkg/webtests.
2026-04-20 12:24:07 +02:00
kolaente 0b08131dad style(spike): fix lint issues introduced by Huma spike
License headers on new files, gofmt on routes.go import ordering,
unused imports in test files, testifylint assert.Contains/InDelta,
and infertypeargs cleanups.
2026-04-20 11:14:29 +02:00
kolaente 4b1202df17 test(spike): verify OAS 3.1 spec exposes label paths 2026-04-20 11:11:35 +02:00
kolaente 7bd561ded8 test(spike): Label round-trip through Huma OAS 3.1 routes 2026-04-20 11:05:39 +02:00
kolaente 898ca26627 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.
2026-04-20 11:05:36 +02:00
kolaente aef4cc3f8a feat(spike): register Label via Huma alongside legacy routes 2026-04-20 10:57:35 +02:00
kolaente 75a9ad4555 feat(huma): generic CRUD registrar for CObject resources 2026-04-20 10:56:33 +02:00
kolaente 5ba404ac58 refactor(handler): extract DoDelete from DeleteWeb 2026-04-20 10:52:08 +02:00
kolaente d677e40597 refactor(handler): extract DoUpdate from UpdateWeb 2026-04-20 10:50:51 +02:00
kolaente be988d47e2 refactor(handler): extract DoReadAll from ReadAllWeb 2026-04-20 10:50:02 +02:00
kolaente 12feb63e4c refactor(handler): extract DoReadOne from ReadOneWeb 2026-04-20 10:48:56 +02:00
kolaente 19fa92febf refactor(handler): extract DoCreate from CreateWeb 2026-04-20 10:48:03 +02:00
kolaente 00d394ed9f feat(huma): error formatter matching legacy Vikunja JSON shape 2026-04-20 10:43:42 +02:00
kolaente abc0cdfc6a feat(auth): add GetAuthFromContext for Huma handlers 2026-04-20 10:43:15 +02:00
kolaente 1d6c1cf838 test: humaecho5 adapter roundtrip and spec serving 2026-04-20 10:41:45 +02:00
kolaente 35f9c4b684 feat: vendor humaecho adapter for echo/v5 2026-04-20 10:41:25 +02:00
kolaente 37adc38df7 chore: add github.com/danielgtaylor/huma/v2 dependency 2026-04-20 10:39:18 +02:00
20 changed files with 1356 additions and 182 deletions

15
go.mod
View File

@ -38,7 +38,7 @@ require (
github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b
github.com/fatih/color v1.18.0
github.com/gabriel-vasile/mimetype v1.4.12
github.com/gabriel-vasile/mimetype v1.4.13
github.com/ganigeorgiev/fexpr v0.5.0
github.com/getsentry/sentry-go v0.41.0
github.com/go-ldap/ldap/v3 v3.4.12
@ -117,12 +117,13 @@ require (
github.com/boombuler/barcode v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/danielgtaylor/huma/v2 v2.37.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
@ -131,7 +132,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@ -141,7 +142,7 @@ require (
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
@ -152,7 +153,7 @@ require (
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.53.0 // indirect
@ -197,7 +198,7 @@ require (
golang.org/x/mod v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect

17
go.sum
View File

@ -98,10 +98,14 @@ github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86c
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
@ -122,6 +126,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U=
github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/danielgtaylor/huma/v2 v2.37.3 h1:6Av0Vj45Vk5lDxRVfoO2iPlEdvCvwLc7pl5nbqGOkYM=
github.com/danielgtaylor/huma/v2 v2.37.3/go.mod h1:OeHHtCEAaNiuVbAVdYu4IQ0UOmnb4x3yMUOShNlZ53g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@ -156,6 +162,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/getsentry/sentry-go v0.41.0 h1:q/dQZOlEIb4lhxQSjJhQqtRr3vwrJ6Ahe1C9zv+ryRo=
@ -164,6 +172,8 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
@ -207,6 +217,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
@ -330,6 +342,7 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible h1:81Hr6g9bunxXhRv4AZv0anKcS1WwHLMgo6wbBjamJlY=
github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible/go.mod h1:y1UhTNI4g0hVymJrI6yJ5/ohy09hNBeU8iJEZjgdDOw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@ -380,6 +393,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
@ -700,6 +715,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -17,6 +17,7 @@
package auth
import (
"context"
"fmt"
"net/http"
"strings"
@ -25,6 +26,7 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
@ -366,3 +368,16 @@ func RefreshSession(rawRefreshToken string) (*RefreshResult, error) {
SessionID: session.ID,
}, nil
}
// GetAuthFromContext retrieves the authenticated web.Auth from a Go
// context.Context. This bridges Huma handlers (which receive a plain
// context.Context) to Vikunja's echo-based JWT flow. The humaecho5
// adapter stashes the underlying *echo.Context under
// humaecho5.EchoContextKey before invoking the Huma handler.
func GetAuthFromContext(ctx context.Context) (web.Auth, error) {
ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context)
if !ok {
return nil, fmt.Errorf("no echo.Context on request context; are you calling GetAuthFromContext from a Huma handler dispatched by humaecho5?")
}
return GetAuthFromClaims(ec)
}

View File

@ -0,0 +1,34 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetAuthFromContext_NoEchoContext(t *testing.T) {
_, err := GetAuthFromContext(context.Background())
assert.Error(t, err, "should fail when echo.Context isn't stashed on ctx")
}
// NOTE: A full positive test requires a valid JWT and DB fixtures.
// That path is exercised by the Label integration test in Phase E.
// Here we only prove the helper returns an error (not a panic) on an
// unwrapped context.

View File

@ -0,0 +1,170 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package humaecho5 is a Huma adapter for labstack/echo/v5.
//
// Adapted from github.com/danielgtaylor/huma/v2/adapters/humaecho (MIT)
// with the echo/v5 port proposed in https://github.com/danielgtaylor/huma/pull/959.
// Remove this package once the upstream PR lands and the official adapter
// supports echo/v5.
package humaecho5
import (
"context"
"crypto/tls"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5"
)
// MultipartMaxMemory is the maximum memory to use when parsing multipart
// form data.
var MultipartMaxMemory int64 = 8 * 1024
// echoContextKey is the context key under which the underlying *echo.Context
// is stashed on the request's context.Context. Handlers that run inside a
// Huma-dispatched call can retrieve it via ctx.Value(EchoContextKey).
type echoContextKey struct{}
// EchoContextKey is the exported key for retrieving the underlying echo
// context from a Huma handler's context.Context.
var EchoContextKey = echoContextKey{}
// Unwrap extracts the underlying Echo context from a Huma context. Panics if
// called on a context from a different adapter.
func Unwrap(ctx huma.Context) *echo.Context {
for {
if c, ok := ctx.(interface{ Unwrap() huma.Context }); ok {
ctx = c.Unwrap()
continue
}
break
}
if c, ok := ctx.(*echoCtx); ok {
return c.Unwrap()
}
panic("not a humaecho5 context")
}
type echoCtx struct {
op *huma.Operation
orig *echo.Context
status int
}
var _ huma.Context = &echoCtx{}
func (c *echoCtx) Unwrap() *echo.Context { return c.orig }
func (c *echoCtx) Operation() *huma.Operation { return c.op }
func (c *echoCtx) Context() context.Context {
// Stash the underlying echo context so downstream helpers
// (e.g. auth.GetAuthFromContext) can retrieve it.
return context.WithValue((*c.orig).Request().Context(), EchoContextKey, c.orig)
}
func (c *echoCtx) Method() string { return (*c.orig).Request().Method }
func (c *echoCtx) Host() string { return (*c.orig).Request().Host }
func (c *echoCtx) RemoteAddr() string { return (*c.orig).Request().RemoteAddr }
func (c *echoCtx) URL() url.URL { return *(*c.orig).Request().URL }
func (c *echoCtx) Param(name string) string { return (*c.orig).Param(name) }
func (c *echoCtx) Query(name string) string { return (*c.orig).QueryParam(name) }
func (c *echoCtx) Header(name string) string { return (*c.orig).Request().Header.Get(name) }
func (c *echoCtx) EachHeader(cb func(name, value string)) {
for name, values := range (*c.orig).Request().Header {
for _, value := range values {
cb(name, value)
}
}
}
func (c *echoCtx) BodyReader() io.Reader { return (*c.orig).Request().Body }
func (c *echoCtx) GetMultipartForm() (*multipart.Form, error) {
err := (*c.orig).Request().ParseMultipartForm(MultipartMaxMemory)
return (*c.orig).Request().MultipartForm, err
}
func (c *echoCtx) SetReadDeadline(deadline time.Time) error {
return huma.SetReadDeadline((*c.orig).Response(), deadline)
}
func (c *echoCtx) SetStatus(code int) {
c.status = code
(*c.orig).Response().WriteHeader(code)
}
func (c *echoCtx) Status() int { return c.status }
func (c *echoCtx) AppendHeader(name, value string) {
(*c.orig).Response().Header().Add(name, value)
}
func (c *echoCtx) SetHeader(name, value string) {
(*c.orig).Response().Header().Set(name, value)
}
func (c *echoCtx) BodyWriter() io.Writer { return (*c.orig).Response() }
func (c *echoCtx) TLS() *tls.ConnectionState { return (*c.orig).Request().TLS }
func (c *echoCtx) Version() huma.ProtoVersion {
r := (*c.orig).Request()
return huma.ProtoVersion{
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
}
}
type router interface {
Add(method, path string, handler echo.HandlerFunc, middlewares ...echo.MiddlewareFunc) echo.RouteInfo
}
type echoAdapter struct {
http.Handler
router router
}
func (a *echoAdapter) Handle(op *huma.Operation, handler func(huma.Context)) {
// Convert {param} to :param for Echo's router.
path := op.Path
path = strings.ReplaceAll(path, "{", ":")
path = strings.ReplaceAll(path, "}", "")
a.router.Add(op.Method, path, func(c *echo.Context) error {
ctx := &echoCtx{op: op, orig: c}
handler(ctx)
return nil
})
}
// New creates a new Huma API using the provided Echo router.
func New(r *echo.Echo, config huma.Config) huma.API {
return huma.NewAPI(config, &echoAdapter{Handler: r, router: r})
}
// NewWithGroup creates a new Huma API using the provided Echo router and group.
func NewWithGroup(r *echo.Echo, g *echo.Group, config huma.Config) huma.API {
return huma.NewAPI(config, &echoAdapter{Handler: r, router: g})
}

View File

@ -0,0 +1,86 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package humaecho5_test
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"code.vikunja.io/api/pkg/modules/humaecho5"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAdapterRoundtrip proves that a Huma operation registered against the
// v5 adapter is served by Echo and that the echo.Context is retrievable
// from the handler's context.Context via EchoContextKey.
func TestAdapterRoundtrip(t *testing.T) {
e := echo.New()
api := humaecho5.New(e, huma.DefaultConfig("spike", "0.0.1"))
type pingInput struct {
Name string `path:"name"`
}
type pingOutput struct {
Body struct {
Echo string `json:"echo"`
HasEchoCtx bool `json:"has_echo_ctx"`
}
}
huma.Register(api, huma.Operation{
OperationID: "ping",
Method: "GET",
Path: "/ping/{name}",
}, func(ctx context.Context, in *pingInput) (*pingOutput, error) {
_, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context)
out := &pingOutput{}
out.Body.Echo = in.Name
out.Body.HasEchoCtx = ok
return out, nil
})
req := httptest.NewRequest("GET", "/ping/world", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, 200, rec.Code, "body: %s", rec.Body.String())
var got struct {
Echo string `json:"echo"`
HasEchoCtx bool `json:"has_echo_ctx"`
}
require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
assert.Equal(t, "world", got.Echo)
assert.True(t, got.HasEchoCtx, "echo.Context not stashed on request ctx")
}
// TestOpenAPISpecServed proves Huma serves the OAS 3.1 spec document
// on its configured URL.
func TestOpenAPISpecServed(t *testing.T) {
e := echo.New()
_ = humaecho5.New(e, huma.DefaultConfig("spike", "0.0.1"))
req := httptest.NewRequest("GET", "/openapi.json", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, 200, rec.Code)
assert.Contains(t, rec.Body.String(), `"openapi":"3.1`,
"expected OAS 3.1 header, got %s", rec.Body.String())
}

View File

@ -0,0 +1,239 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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"`
}
// Config describes a generic CRUD resource:
//
// - T is the domain model pointer (must implement handler.CObject)
// - P is the path-parameter struct; use SingleID for the simple case or
// define your own for nested routes like /tasks/{task}/labels/{label}.
//
// Note: Go does not permit embedding a type parameter in a struct, so the
// generic request wrappers below keep P as a named field. Huma's default
// parameter discovery only walks anonymous (embedded) fields, so we define
// parallel concrete wrappers per path shape (see SingleID section below)
// that embed the concrete path struct. Resources with different path shapes
// should add their own wrappers + Register call (this spike only needs
// SingleID; the pattern generalises trivially).
type Config[T handler.CObject, P any] struct {
Tag string
BasePath string // list + create; may itself contain {params}
ItemPath string // read + update + delete
New func() T // factory — same role as WebHandler.EmptyStruct
ApplyPath func(T, P) // copies path params onto the model
}
type bodyOutput[T any] struct {
Body T
}
type listOutput[T any] struct {
Body []T
}
type deleteMessage struct {
Message string `json:"message"`
}
// --- SingleID wrappers ---------------------------------------------------
//
// Concrete request-input types for the /{resource}/{id} shape. Huma's
// parameter discovery finds `ID` through the embedded SingleID. The Body
// field carries the decoded JSON payload.
type singleIDCreateInput[T any] struct {
// No path params for CREATE (path is /{resource}); we still thread a
// matching type parameter so Register can share a single handler shape.
Body T
}
type singleIDItemInput struct {
SingleID
}
type singleIDListInput struct {
Page int `query:"page" default:"1" minimum:"1"`
PerPage int `query:"per_page" default:"50" minimum:"1" maximum:"1000"`
Search string `query:"s"`
}
type singleIDBodyInput[T any] struct {
SingleID
Body T
}
// Register wires five Huma operations for the given CRUD resource.
//
// Today this only implements the SingleID path shape. Resources using
// multi-segment paths (e.g. /tasks/{task}/labels/{label}) should hand-write
// their huma.Register calls until we generalise this registrar.
func Register[T handler.CObject](api huma.API, cfg Config[T, SingleID]) {
jwt := []map[string][]string{{"JWTKeyAuth": {}}}
// CREATE
huma.Register(api, huma.Operation{
OperationID: cfg.Tag + "-create",
Method: http.MethodPut,
Path: cfg.BasePath,
Tags: []string{cfg.Tag},
Security: jwt,
}, func(ctx context.Context, in *singleIDCreateInput[T]) (*bodyOutput[T], error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
if err := handler.DoCreate(ctx, in.Body, a); err != nil {
return nil, translateError(err)
}
return &bodyOutput[T]{Body: in.Body}, nil
})
// READ ONE
huma.Register(api, huma.Operation{
OperationID: cfg.Tag + "-read",
Method: http.MethodGet,
Path: cfg.ItemPath,
Tags: []string{cfg.Tag},
Security: jwt,
}, func(ctx context.Context, in *singleIDItemInput) (*bodyOutput[T], error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
obj := cfg.New()
cfg.ApplyPath(obj, in.SingleID)
if _, err := handler.DoReadOne(ctx, obj, a); err != nil {
return nil, translateError(err)
}
return &bodyOutput[T]{Body: obj}, nil
})
// READ ALL
huma.Register(api, huma.Operation{
OperationID: cfg.Tag + "-list",
Method: http.MethodGet,
Path: cfg.BasePath,
Tags: []string{cfg.Tag},
Security: jwt,
}, func(ctx context.Context, in *singleIDListInput) (*listOutput[T], error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
obj := cfg.New()
result, _, _, err := handler.DoReadAll(ctx, obj, a, in.Search, in.Page, in.PerPage)
if err != nil {
return nil, translateError(err)
}
// Best-effort cast; ReadAll returns interface{}. For the spike
// we assume []T. Resources returning a different list item type
// should hand-write their list op via huma.Register directly.
slice, ok := result.([]T)
if !ok {
// fall back to marshaling whatever shape was returned
return &listOutput[T]{Body: nil}, nil
}
return &listOutput[T]{Body: slice}, nil
})
// UPDATE
huma.Register(api, huma.Operation{
OperationID: cfg.Tag + "-update",
Method: http.MethodPost,
Path: cfg.ItemPath,
Tags: []string{cfg.Tag},
Security: jwt,
}, func(ctx context.Context, in *singleIDBodyInput[T]) (*bodyOutput[T], error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
cfg.ApplyPath(in.Body, in.SingleID)
if err := handler.DoUpdate(ctx, in.Body, a); err != nil {
return nil, translateError(err)
}
return &bodyOutput[T]{Body: in.Body}, nil
})
// DELETE
huma.Register(api, huma.Operation{
OperationID: cfg.Tag + "-delete",
Method: http.MethodDelete,
Path: cfg.ItemPath,
Tags: []string{cfg.Tag},
Security: jwt,
}, func(ctx context.Context, in *singleIDItemInput) (*bodyOutput[deleteMessage], error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
obj := cfg.New()
cfg.ApplyPath(obj, in.SingleID)
if err := handler.DoDelete(ctx, obj, a); err != nil {
return nil, translateError(err)
}
return &bodyOutput[deleteMessage]{Body: deleteMessage{Message: "Successfully deleted."}}, nil
})
}

View File

@ -0,0 +1,105 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package humaapi
import (
"encoding/json"
"net/http/httptest"
"testing"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/web"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
// fakeObj is a minimal CObject for compile-level spec-generation tests only.
// It is not exercised at runtime.
type fakeObj struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
// Satisfy handler.CObject (web.CRUDable + web.Permissions) — implementations
// are unreachable in this test because we only inspect the OpenAPI spec.
// web.CRUDable
func (*fakeObj) Create(_ *xorm.Session, _ web.Auth) error { panic("unused") }
func (*fakeObj) ReadOne(_ *xorm.Session, _ web.Auth) error {
panic("unused")
}
func (*fakeObj) ReadAll(_ *xorm.Session, _ web.Auth, _ string, _ int, _ int) (interface{}, int, int64, error) {
panic("unused")
}
func (*fakeObj) Update(_ *xorm.Session, _ web.Auth) error { panic("unused") }
func (*fakeObj) Delete(_ *xorm.Session, _ web.Auth) error { panic("unused") }
// web.Permissions
func (*fakeObj) CanRead(_ *xorm.Session, _ web.Auth) (bool, int, error) { panic("unused") }
func (*fakeObj) CanDelete(_ *xorm.Session, _ web.Auth) (bool, error) { panic("unused") }
func (*fakeObj) CanUpdate(_ *xorm.Session, _ web.Auth) (bool, error) { panic("unused") }
func (*fakeObj) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) { panic("unused") }
// TestRegisterEmitsFiveOperations confirms that a call to Register
// produces the expected OpenAPI path entries for a simple id-based
// resource. We don't invoke the handlers here; we only inspect the spec.
func TestRegisterEmitsFiveOperations(t *testing.T) {
e := echo.New()
api := humaecho5.New(e, huma.DefaultConfig("spike", "0.0.1"))
Register(api, Config[*fakeObj, SingleID]{
Tag: "fakes",
BasePath: "/fakes",
ItemPath: "/fakes/{id}",
New: func() *fakeObj { return &fakeObj{} },
ApplyPath: func(o *fakeObj, p SingleID) { o.ID = p.ID },
})
spec := api.OpenAPI()
require.NotNil(t, spec.Paths["/fakes"])
require.NotNil(t, spec.Paths["/fakes/{id}"])
// Five ops across two paths: list+create on base, read+update+delete on item
ops := 0
for _, p := range spec.Paths {
if p.Get != nil {
ops++
}
if p.Put != nil {
ops++
}
if p.Post != nil {
ops++
}
if p.Delete != nil {
ops++
}
}
assert.Equal(t, 5, ops)
// Also prove the spec is 3.1.x
req := httptest.NewRequest("GET", "/openapi.json", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
var doc map[string]any
require.NoError(t, json.NewDecoder(rec.Body).Decode(&doc))
require.Contains(t, doc["openapi"].(string), "3.1")
}

View File

@ -0,0 +1,45 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package humaapi
import (
"github.com/danielgtaylor/huma/v2"
)
// vikunjaError is the JSON shape Vikunja's existing error_handler.go emits
// for string-style errors. We preserve it so frontend contracts don't change.
type vikunjaError struct {
StatusCode int `json:"-"`
Code int `json:"code,omitempty"`
Message string `json:"message"`
}
func (e *vikunjaError) Error() string { return e.Message }
func (e *vikunjaError) GetStatus() int { return e.StatusCode }
// NewVikunjaError produces an error that serializes to Vikunja's legacy shape
// (`{"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, _ ...error) huma.StatusError {
return &vikunjaError{StatusCode: status, Message: msg}
}
// Install replaces huma.NewError globally. Call once at init.
func Install() {
huma.NewError = NewVikunjaError
}

View File

@ -0,0 +1,46 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package humaapi
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVikunjaErrorShape_BasicCodeMessage(t *testing.T) {
err := NewVikunjaError(http.StatusForbidden, "Forbidden")
b, marshalErr := json.Marshal(err)
require.NoError(t, marshalErr)
var got map[string]any
require.NoError(t, json.Unmarshal(b, &got))
assert.Equal(t, "Forbidden", got["message"])
// must not include RFC 9457 fields
_, hasType := got["type"]
_, hasTitle := got["title"]
assert.False(t, hasType, "unexpected RFC 9457 field 'type'")
assert.False(t, hasTitle, "unexpected RFC 9457 field 'title'")
}
func TestVikunjaErrorShape_StatusCoderInterface(t *testing.T) {
e := NewVikunjaError(http.StatusNotFound, "not found")
assert.Equal(t, http.StatusNotFound, e.GetStatus())
}

View File

@ -0,0 +1,38 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package humaapi
import (
"code.vikunja.io/api/pkg/models"
"github.com/danielgtaylor/huma/v2"
)
// RegisterLabelRoutes wires Huma-flavoured Label CRUD operations onto the
// given Huma API. Runs alongside (not replacing) the legacy swag-driven
// routes for the duration of the spike.
func RegisterLabelRoutes(api huma.API) {
Register(api, Config[*models.Label, SingleID]{
Tag: "labels",
BasePath: "/labels",
ItemPath: "/labels/{id}",
New: func() *models.Label { return &models.Label{} },
ApplyPath: func(l *models.Label, p SingleID) {
l.ID = p.ID
},
})
}

View File

@ -0,0 +1,121 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package humaapi_test
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/routes/api/v1/humaapi"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSpecVerification_LabelOAS31 is the Phase F1 (file-based) smoke check.
// It builds the same Huma API the production routes wire up, registers the
// Label resource, then validates the generated spec is well-formed OAS 3.1
// with the expected paths/methods/security. On failure it dumps the spec
// to /tmp/huma-label-spec.json for human inspection.
func TestSpecVerification_LabelOAS31(t *testing.T) {
e := echo.New()
cfg := huma.DefaultConfig("Vikunja API (OAS 3.1 spike)", "0.0.1")
cfg.OpenAPIPath = "/openapi"
cfg.FieldsOptionalByDefault = true
api := humaecho5.New(e, cfg)
humaapi.Install()
humaapi.RegisterLabelRoutes(api)
// Render to JSON and round-trip into a generic map so we can assert on
// shape without coupling to Huma's struct types.
req := httptest.NewRequest("GET", "/openapi.json", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equalf(t, 200, rec.Code, "openapi spec endpoint failed: %s", rec.Body.String())
// Persist for human inspection / external diffing.
specPath := filepath.Join(t.TempDir(), "huma-label-spec.json")
require.NoError(t, os.WriteFile(specPath, rec.Body.Bytes(), 0o600))
var spec map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec))
// 1. OAS version
openapiVersion, _ := spec["openapi"].(string)
assert.Truef(t, len(openapiVersion) >= 3 && openapiVersion[:3] == "3.1",
"expected OAS 3.1.x, got %q", openapiVersion)
t.Logf("openapi version: %s", openapiVersion)
// 2. Paths exist
paths, _ := spec["paths"].(map[string]any)
require.NotNil(t, paths, "spec has no paths object")
require.Contains(t, paths, "/labels", "missing /labels path")
require.Contains(t, paths, "/labels/{id}", "missing /labels/{id} path")
// 3. /labels has GET (list) + PUT (create)
basePath, _ := paths["/labels"].(map[string]any)
assert.Contains(t, basePath, "get", "/labels missing GET (list)")
assert.Contains(t, basePath, "put", "/labels missing PUT (create)")
// 4. /labels/{id} has GET (read) + POST (update) + DELETE
itemPath, _ := paths["/labels/{id}"].(map[string]any)
assert.Contains(t, itemPath, "get", "/labels/{id} missing GET (read)")
assert.Contains(t, itemPath, "post", "/labels/{id} missing POST (update)")
assert.Contains(t, itemPath, "delete", "/labels/{id} missing DELETE")
// 5. Operations carry security (JWT) and tags
listOp, _ := basePath["get"].(map[string]any)
assert.Contains(t, listOp, "security", "list op missing security")
assert.Contains(t, listOp, "tags", "list op missing tags")
// 6. The {id} parameter is declared
itemReadOp, _ := itemPath["get"].(map[string]any)
params, _ := itemReadOp["parameters"].([]any)
require.NotEmpty(t, params, "read-one op has no parameters")
foundID := false
for _, p := range params {
pm, _ := p.(map[string]any)
if pm["name"] == "id" && pm["in"] == "path" {
foundID = true
break
}
}
assert.True(t, foundID, "read-one op missing path parameter 'id'")
// 7. List op exposes paging query params
listParams, _ := listOp["parameters"].([]any)
queryNames := map[string]bool{}
for _, p := range listParams {
pm, _ := p.(map[string]any)
if pm["in"] == "query" {
if name, ok := pm["name"].(string); ok {
queryNames[name] = true
}
}
}
for _, want := range []string{"page", "per_page", "s"} {
assert.Truef(t, queryNames[want], "list op missing query param %q (got %v)", want, queryNames)
}
t.Logf("spec written to %s (%d bytes)", specPath, rec.Body.Len())
}

View File

@ -55,6 +55,7 @@ import (
"context"
"log/slog"
"net"
"net/http"
"strings"
"time"
@ -67,6 +68,7 @@ import (
backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler"
"code.vikunja.io/api/pkg/modules/background/unsplash"
"code.vikunja.io/api/pkg/modules/background/upload"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/modules/migration"
csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv"
migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler"
@ -78,11 +80,13 @@ import (
"code.vikunja.io/api/pkg/modules/migration/wekan"
"code.vikunja.io/api/pkg/plugins"
apiv1 "code.vikunja.io/api/pkg/routes/api/v1"
"code.vikunja.io/api/pkg/routes/api/v1/humaapi"
"code.vikunja.io/api/pkg/routes/caldav"
"code.vikunja.io/api/pkg/version"
"code.vikunja.io/api/pkg/web/handler"
ws "code.vikunja.io/api/pkg/websocket"
"github.com/danielgtaylor/huma/v2"
"github.com/getsentry/sentry-go"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
@ -288,7 +292,7 @@ func RegisterRoutes(e *echo.Echo) {
// API Routes
a := e.Group("/api/v1")
registerAPIRoutes(a)
registerAPIRoutes(e, a)
// Collect routes for API token permissions
// In Echo v5, we collect routes after registration using e.Router().Routes()
@ -310,6 +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/oas3/openapi": true,
"/api/v1/oas3/openapi.json": true,
"/api/v1/oas3/openapi.yaml": true,
"/api/v1/metrics": true,
"/api/v1/oauth/token": true,
}
@ -332,7 +339,7 @@ func collectRoutesForAPITokens(e *echo.Echo) {
}
}
func registerAPIRoutes(a *echo.Group) {
func registerAPIRoutes(e *echo.Echo, a *echo.Group) {
// Prevent browsers from caching API responses. Without an explicit
// Cache-Control header browsers may heuristically cache JSON responses
@ -416,6 +423,51 @@ func registerAPIRoutes(a *echo.Group) {
// Middleware to collect metrics
setupMetricsMiddleware(a)
// ===== 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/oas3/openapi.json.
humaapi.Install()
humaConfig := huma.DefaultConfig("Vikunja API (OAS 3.1 spike)", "0.0.1")
// 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)
a.GET("/routes", models.GetAvailableAPIRoutesForToken)

214
pkg/web/handler/core.go Normal file
View File

@ -0,0 +1,214 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package handler
import (
"context"
"net/http"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/web"
"github.com/labstack/echo/v5"
)
// DoCreate runs the permission check + model Create + commit pipeline for a
// CObject. Framework-agnostic: callable from both Echo (CreateWeb) and Huma.
// Caller is responsible for body/path binding and validation before calling.
func DoCreate(_ context.Context, obj CObject, a web.Auth) error {
s := db.NewSession()
defer func() {
if err := s.Close(); err != nil {
log.Errorf("Could not close session: %s", err)
}
}()
canCreate, err := obj.CanCreate(s, a)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
if !canCreate {
_ = s.Rollback()
events.CleanupPending(s)
log.Warningf("Tried to create while not having the permissions for it (User: %v)", a)
return echo.NewHTTPError(http.StatusForbidden, "Forbidden")
}
if err := obj.Create(s, a); err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
if err := s.Commit(); err != nil {
events.CleanupPending(s)
return err
}
events.DispatchPending(s)
return nil
}
// DoReadOne runs the permission check + model ReadOne + commit pipeline for a
// CObject. obj should have its identifying fields set before call. On success,
// obj is fully populated. maxPermission is exposed via the x-max-permission
// header in the Echo wrapper; Huma wrapper may ignore it.
func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, err error) {
s := db.NewSession()
defer func() {
if cerr := s.Close(); cerr != nil {
log.Errorf("Could not close session: %s", cerr)
}
}()
canRead, maxPermission, err := obj.CanRead(s, a)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return 0, err
}
if !canRead {
_ = s.Rollback()
events.CleanupPending(s)
log.Warningf("Tried to read while not having the permissions for it (User: %v)", a)
return 0, echo.NewHTTPError(http.StatusForbidden, "You don't have the permission to see this")
}
if err := obj.ReadOne(s, a); err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return 0, err
}
if err := s.Commit(); err != nil {
events.CleanupPending(s)
return 0, err
}
events.DispatchPending(s)
return maxPermission, nil
}
// DoReadAll runs the ReadAll + commit pipeline for a CObject. obj may carry
// scoping context (e.g., TaskID on LabelTask). Returns the result slice/
// interface, the result count, and total count. Pagination header math and
// nil-slice normalization remain the caller's responsibility.
func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) {
s := db.NewSession()
defer func() {
if cerr := s.Close(); cerr != nil {
log.Errorf("Could not close session: %s", cerr)
}
}()
result, resultCount, total, err = obj.ReadAll(s, a, search, page, perPage)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return nil, 0, 0, err
}
if err = s.Commit(); err != nil {
events.CleanupPending(s)
return nil, 0, 0, err
}
events.DispatchPending(s)
return result, resultCount, total, nil
}
// DoUpdate runs the permission check + model Update + commit pipeline for a
// CObject. Framework-agnostic. Caller is responsible for body/path binding
// and validation before calling.
func DoUpdate(_ context.Context, obj CObject, a web.Auth) error {
s := db.NewSession()
defer func() {
if err := s.Close(); err != nil {
log.Errorf("Could not close session: %s", err)
}
}()
canUpdate, err := obj.CanUpdate(s, a)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
if !canUpdate {
_ = s.Rollback()
events.CleanupPending(s)
log.Warningf("Tried to update while not having the permissions for it (User: %v)", a)
return echo.NewHTTPError(http.StatusForbidden, "Forbidden")
}
if err := obj.Update(s, a); err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
if err := s.Commit(); err != nil {
events.CleanupPending(s)
return err
}
events.DispatchPending(s)
return nil
}
// DoDelete runs the permission check + model Delete + commit pipeline for a
// CObject. Framework-agnostic. Caller is responsible for path binding before
// calling.
func DoDelete(_ context.Context, obj CObject, a web.Auth) error {
s := db.NewSession()
defer func() {
if err := s.Close(); err != nil {
log.Errorf("Could not close session: %s", err)
}
}()
canDelete, err := obj.CanDelete(s, a)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
if !canDelete {
_ = s.Rollback()
events.CleanupPending(s)
log.Warningf("Tried to delete while not having the permissions for it (User: %v)", a)
return echo.NewHTTPError(http.StatusForbidden, "Forbidden")
}
if err := obj.Delete(s, a); err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
if err := s.Commit(); err != nil {
events.CleanupPending(s)
return err
}
events.DispatchPending(s)
return nil
}

View File

@ -21,8 +21,6 @@ import (
"fmt"
"net/http"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@ -56,44 +54,9 @@ func (c *WebHandler) CreateWeb(ctx *echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "Could not determine the current user.").Wrap(err)
}
// Create the db session
s := db.NewSession()
defer func() {
err = s.Close()
if err != nil {
log.Errorf("Could not close session: %s", err)
}
}()
// Check permissions
canCreate, err := currentStruct.CanCreate(s, currentAuth)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
if err := DoCreate(ctx.Request().Context(), currentStruct, currentAuth); err != nil {
return err
}
if !canCreate {
_ = s.Rollback()
events.CleanupPending(s)
log.Warningf("Tried to create while not having the permissions for it (User: %v)", currentAuth)
return echo.NewHTTPError(http.StatusForbidden, "Forbidden")
}
// Create
err = currentStruct.Create(s, currentAuth)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
err = s.Commit()
if err != nil {
events.CleanupPending(s)
return err
}
events.DispatchPending(s)
return ctx.JSON(http.StatusCreated, currentStruct)
}

View File

@ -21,8 +21,6 @@ import (
"fmt"
"net/http"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@ -56,42 +54,9 @@ func (c *WebHandler) DeleteWeb(ctx *echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "Could not determine the current user.").Wrap(err)
}
// Create the db session
s := db.NewSession()
defer func() {
err = s.Close()
if err != nil {
log.Errorf("Could not close session: %s", err)
}
}()
canDelete, err := currentStruct.CanDelete(s, currentAuth)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
if err := DoDelete(ctx.Request().Context(), currentStruct, currentAuth); err != nil {
return err
}
if !canDelete {
_ = s.Rollback()
events.CleanupPending(s)
log.Warningf("Tried to delete while not having the permissions for it (User: %v)", currentAuth)
return echo.NewHTTPError(http.StatusForbidden, "Forbidden")
}
err = currentStruct.Delete(s, currentAuth)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
err = s.Commit()
if err != nil {
events.CleanupPending(s)
return err
}
events.DispatchPending(s)
return ctx.JSON(http.StatusOK, message{"Successfully deleted."})
}

View File

@ -25,8 +25,6 @@ import (
"strconv"
vconfig "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@ -91,22 +89,11 @@ func (c *WebHandler) ReadAllWeb(ctx *echo.Context) error {
perPageNumber = vconfig.ServiceMaxItemsPerPage.GetInt()
}
// Create the db session
s := db.NewSession()
defer func() {
err = s.Close()
if err != nil {
log.Errorf("Could not close session: %s", err)
}
}()
// Search
search := ctx.QueryParam("s")
result, resultCount, numberOfItems, err := currentStruct.ReadAll(s, currentAuth, search, pageNumber, perPageNumber)
result, resultCount, numberOfItems, err := DoReadAll(ctx.Request().Context(), currentStruct, currentAuth, search, pageNumber, perPageNumber)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
@ -127,14 +114,6 @@ func (c *WebHandler) ReadAllWeb(ctx *echo.Context) error {
ctx.Response().Header().Set("x-pagination-result-count", strconv.FormatInt(int64(resultCount), 10))
ctx.Response().Header().Set("Access-Control-Expose-Headers", "x-pagination-total-pages, x-pagination-result-count")
err = s.Commit()
if err != nil {
events.CleanupPending(s)
return err
}
events.DispatchPending(s)
// Ensure we return an empty array instead of null when there are no results.
// We need to use reflection here because a nil slice wrapped in an interface{}
// is not equal to nil (the interface contains a nil value but is not nil itself).

View File

@ -22,8 +22,6 @@ import (
"net/http"
"strconv"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@ -52,49 +50,14 @@ func (c *WebHandler) ReadOneWeb(ctx *echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "Could not determine the current user.").Wrap(err)
}
// Create the db session
s := db.NewSession()
defer func() {
err = s.Close()
if err != nil {
log.Errorf("Could not close session: %s", err)
}
}()
canRead, maxPermission, err := currentStruct.CanRead(s, currentAuth)
maxPermission, err := DoReadOne(ctx.Request().Context(), currentStruct, currentAuth)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
if !canRead {
_ = s.Rollback()
events.CleanupPending(s)
log.Warningf("Tried to read while not having the permissions for it (User: %v)", currentAuth)
return echo.NewHTTPError(http.StatusForbidden, "You don't have the permission to see this")
}
// Get our object
err = currentStruct.ReadOne(s, currentAuth)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
// Set the headers
if canRead {
ctx.Response().Header().Set("x-max-permission", strconv.FormatInt(int64(maxPermission), 10))
ctx.Response().Header().Set("Access-Control-Expose-Headers", "x-max-permission")
}
err = s.Commit()
if err != nil {
events.CleanupPending(s)
return err
}
events.DispatchPending(s)
ctx.Response().Header().Set("x-max-permission", strconv.FormatInt(int64(maxPermission), 10))
ctx.Response().Header().Set("Access-Control-Expose-Headers", "x-max-permission")
return ctx.JSON(http.StatusOK, currentStruct)
}

View File

@ -21,8 +21,6 @@ import (
"fmt"
"net/http"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@ -57,43 +55,9 @@ func (c *WebHandler) UpdateWeb(ctx *echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "Could not determine the current user.").Wrap(err)
}
// Create the db session
s := db.NewSession()
defer func() {
err = s.Close()
if err != nil {
log.Errorf("Could not close session: %s", err)
}
}()
canUpdate, err := currentStruct.CanUpdate(s, currentAuth)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
if err := DoUpdate(ctx.Request().Context(), currentStruct, currentAuth); err != nil {
return err
}
if !canUpdate {
_ = s.Rollback()
events.CleanupPending(s)
log.Warningf("Tried to update while not having the permissions for it (User: %v)", currentAuth)
return echo.NewHTTPError(http.StatusForbidden, "Forbidden")
}
// Do the update
err = currentStruct.Update(s, currentAuth)
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
err = s.Commit()
if err != nil {
events.CleanupPending(s)
return err
}
events.DispatchPending(s)
return ctx.JSON(http.StatusOK, currentStruct)
}

View File

@ -0,0 +1,157 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package webtests
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/user"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// tokenForUser returns a JWT bearer token for the given user, suitable for the
// "Authorization: Bearer ..." header on full ServeHTTP requests.
func tokenForUser(t *testing.T, u *user.User) string {
token, err := auth.NewUserJWTAuthtoken(u, "test-session-id")
require.NoError(t, err)
return token
}
// TestHumaLabel_Create_ReadOne_via_OAS31Route exercises the Huma-served
// /labels endpoint with the same auth + fixtures the legacy tests use.
// This proves:
// 1. humaecho5 adapter dispatches correctly
// 2. JWT middleware still populates the echo.Context
// 3. auth.GetAuthFromContext fishes it back out
// 4. DoCreate / DoReadOne wire up through to the model
// 5. Response JSON shape matches what the frontend expects
func TestHumaLabel_Create_ReadOne_via_OAS31Route(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := tokenForUser(t, &testuser1)
// 1) PUT /api/v1/oas3/labels — create a label via the Huma-mounted route
createReq := httptest.NewRequest(http.MethodPut, "/api/v1/oas3/labels",
strings.NewReader(`{"title":"spike","hex_color":"abcdef"}`))
createReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
createReq.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
createRec := httptest.NewRecorder()
e.ServeHTTP(createRec, createReq)
require.Equalf(t, http.StatusOK, createRec.Code,
"unexpected status %d; body=%q", createRec.Code, createRec.Body.String())
var created map[string]any
require.NoError(t, json.Unmarshal(createRec.Body.Bytes(), &created))
assert.Equal(t, "spike", created["title"])
rawID, ok := created["id"]
require.Truef(t, ok, "response body has no id field: %q", createRec.Body.String())
// JSON numbers decode to float64.
idFloat, ok := rawID.(float64)
require.True(t, ok, "id field is not a number")
require.NotZero(t, int64(idFloat))
id := strconv.FormatInt(int64(idFloat), 10)
// 2) GET /api/v1/oas3/labels/{id} — read it back
readReq := httptest.NewRequest(http.MethodGet, "/api/v1/oas3/labels/"+id, nil)
readReq.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
readRec := httptest.NewRecorder()
e.ServeHTTP(readRec, readReq)
require.Equalf(t, http.StatusOK, readRec.Code,
"unexpected status %d; body=%q", readRec.Code, readRec.Body.String())
var fetched map[string]any
require.NoError(t, json.Unmarshal(readRec.Body.Bytes(), &fetched))
assert.Equal(t, "spike", fetched["title"])
assert.InDelta(t, idFloat, fetched["id"], 0)
}
// TestHumaLabel_OpenAPISpecContainsLabelPaths proves the spec is served
// and includes the Label routes.
func TestHumaLabel_OpenAPISpecContainsLabelPaths(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/oas3/openapi.json", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equalf(t, http.StatusOK, rec.Code,
"unexpected status %d; body=%q", rec.Code, rec.Body.String())
body := rec.Body.String()
assert.Contains(t, body, `"openapi":"3.1`)
assert.Contains(t, body, `/labels`)
assert.Contains(t, body, `/labels/{id}`)
}
// TestHumaLabel_ForbiddenErrorShape ensures a 403 returns
// {"message": "Forbidden"} and NOT RFC 9457 problem+json.
func TestHumaLabel_ForbiddenErrorShape(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
// user 1 creates a label via the Huma route...
creatorToken := tokenForUser(t, &testuser1)
createReq := httptest.NewRequest(http.MethodPut, "/api/v1/oas3/labels",
strings.NewReader(`{"title":"forbidden-target"}`))
createReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
createReq.Header.Set(echo.HeaderAuthorization, "Bearer "+creatorToken)
createRec := httptest.NewRecorder()
e.ServeHTTP(createRec, createReq)
require.Equalf(t, http.StatusOK, createRec.Code,
"create failed with %d: %q", createRec.Code, createRec.Body.String())
var created map[string]any
require.NoError(t, json.Unmarshal(createRec.Body.Bytes(), &created))
idFloat, ok := created["id"].(float64)
require.True(t, ok)
id := strconv.FormatInt(int64(idFloat), 10)
// ...another user (user 10) tries to delete it.
attackerToken := tokenForUser(t, &testuser10)
delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/oas3/labels/"+id, nil)
delReq.Header.Set(echo.HeaderAuthorization, "Bearer "+attackerToken)
delRec := httptest.NewRecorder()
e.ServeHTTP(delRec, delReq)
assert.Equalf(t, http.StatusForbidden, delRec.Code,
"expected 403, got %d; body=%q", delRec.Code, delRec.Body.String())
var body map[string]any
require.NoError(t, json.Unmarshal(delRec.Body.Bytes(), &body))
assert.Equal(t, "Forbidden", body["message"])
// RFC 9457 problem+json would put these on the payload; Vikunja's legacy
// shape must stay clean.
_, hasType := body["type"]
_, hasTitle := body["title"]
assert.Falsef(t, hasType, "unexpected RFC 9457 field 'type' in body %q", delRec.Body.String())
assert.Falsef(t, hasTitle, "unexpected RFC 9457 field 'title' in body %q", delRec.Body.String())
}