Compare commits
17 Commits
main
...
spike-huma
| Author | SHA1 | Date |
|---|---|---|
|
|
692249fa04 | |
|
|
0b08131dad | |
|
|
4b1202df17 | |
|
|
7bd561ded8 | |
|
|
898ca26627 | |
|
|
aef4cc3f8a | |
|
|
75a9ad4555 | |
|
|
5ba404ac58 | |
|
|
d677e40597 | |
|
|
be988d47e2 | |
|
|
12feb63e4c | |
|
|
19fa92febf | |
|
|
00d394ed9f | |
|
|
abc0cdfc6a | |
|
|
1d6c1cf838 | |
|
|
35f9c4b684 | |
|
|
37adc38df7 |
15
go.mod
15
go.mod
|
|
@ -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
17
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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})
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
Loading…
Reference in New Issue