feat(api/v2): port Label to per-operation Huma handlers

Wires five hand-written huma.Register calls for Label CRUD onto the
existing /api/v2 group: list, read, create, update, delete. Uses
concrete type cast on ReadAll to avoid the generic-any silent-empty
trap. The read operation exposes an ETag via a header-tagged output
struct field and honours conditional.Params so clients can get 304
Not Modified on subsequent reads.

Also closes a prior-phase gap: SetupTokenMiddleware was intended to
run on the /api/v2 group (per task B4 of the plan) but was never
wired. Attach it now and teach the skipper to consult
unauthenticatedAPIPaths so spec + docs remain public.
This commit is contained in:
kolaente 2026-04-21 13:13:34 +02:00 committed by kolaente
parent b52a451db4
commit a2156e7231
6 changed files with 323 additions and 7 deletions

View File

@ -0,0 +1,57 @@
// 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 apiv2
import (
"context"
"errors"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/web"
"github.com/danielgtaylor/huma/v2"
)
// authFromCtx retrieves the authed user from a Huma handler context,
// surfacing lookup failures as 401 instead of falling through to 500.
func authFromCtx(ctx context.Context) (web.Auth, error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
return a, nil
}
// translateDomainError maps a Vikunja domain error (web.HTTPErrorProcessor)
// onto Huma's status-error type so the response carries the right code
// and an RFC 9457 body. Errors without HTTP semantics fall through, which
// Huma treats as 500.
func translateDomainError(err error) error {
if err == nil {
return nil
}
var hp web.HTTPErrorProcessor
if errors.As(err, &hp) {
details := hp.HTTPError()
msg := details.Message
if msg == "" {
msg = err.Error()
}
return huma.NewError(details.HTTPCode, msg)
}
return err
}

View File

@ -18,6 +18,9 @@
package apiv2
import (
"context"
"net/http"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/version"
@ -57,12 +60,30 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API {
Scheme: "bearer",
Description: "Vikunja API token (tk_ prefix) with scoped permissions. Created via /api/v1/tokens.",
}
// Applied globally to every registered operation; the handful of public
// endpoints (spec, docs) explicitly opt out with Security: []map[...]{}.
// Applied globally; public endpoints (spec, docs) opt out with an empty Security list.
oapi.Security = []map[string][]string{
{"JWTKeyAuth": {}},
{"APITokenAuth": {}},
}
autopatch.AutoPatch(api)
return api
}
// Register wraps huma.Register with verb-based DefaultStatus: POST → 201,
// DELETE → 204. Anything else (including an explicit op.DefaultStatus) is untouched.
func Register[I, O any](api huma.API, op huma.Operation, handler func(context.Context, *I) (*O, error)) {
if op.DefaultStatus == 0 {
switch op.Method {
case http.MethodPost:
op.DefaultStatus = http.StatusCreated
case http.MethodDelete:
op.DefaultStatus = http.StatusNoContent
}
}
huma.Register(api, op, handler)
}
// EnableAutoPatch synthesises a PATCH for every resource that already
// registered GET + PUT. Must be called AFTER all Register* calls.
func EnableAutoPatch(api huma.API) {
autopatch.AutoPatch(api)
}

155
pkg/routes/api/v2/labels.go Normal file
View File

@ -0,0 +1,155 @@
// 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 apiv2
import (
"context"
"fmt"
"net/http"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/web/handler"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/conditional"
)
// Element type is *models.LabelWithTaskID because that's what
// models.Label.ReadAll returns; TaskID is json:"-", so the wire shape
// matches plain Label.
type labelListBody struct {
Body Paginated[*models.LabelWithTaskID]
}
// RegisterLabelRoutes wires Label CRUD onto the Huma API.
func RegisterLabelRoutes(api huma.API) {
tags := []string{"labels"}
Register(api, huma.Operation{
OperationID: "labels-list",
Method: http.MethodGet,
Path: "/labels",
Tags: tags,
}, labelsList)
Register(api, huma.Operation{
OperationID: "labels-read",
Method: http.MethodGet,
Path: "/labels/{id}",
Tags: tags,
}, labelsRead)
Register(api, huma.Operation{
OperationID: "labels-create",
Method: http.MethodPost,
Path: "/labels",
Tags: tags,
}, labelsCreate)
Register(api, huma.Operation{
OperationID: "labels-update",
Method: http.MethodPut,
Path: "/labels/{id}",
Tags: tags,
}, labelsUpdate)
Register(api, huma.Operation{
OperationID: "labels-delete",
Method: http.MethodDelete,
Path: "/labels/{id}",
Tags: tags,
}, labelsDelete)
}
func labelsList(ctx context.Context, in *ListParams) (*labelListBody, error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
result, _, total, err := handler.DoReadAll(ctx, &models.Label{}, a, in.Q, in.Page, in.PerPage)
if err != nil {
return nil, translateDomainError(err)
}
items, ok := result.([]*models.LabelWithTaskID)
if !ok {
return nil, fmt.Errorf("labels.ReadAll returned unexpected type %T (expected []*models.LabelWithTaskID)", result)
}
return &labelListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
}
func labelsRead(ctx context.Context, in *struct {
ID int64 `path:"id"`
conditional.Params
}) (*singleReadBody[models.Label], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
label := &models.Label{ID: in.ID}
if _, err := handler.DoReadOne(ctx, label, a); err != nil {
return nil, translateDomainError(err)
}
// PreconditionFailed wants the unquoted etag; response header uses RFC 9110 quoted form.
etag := fmt.Sprintf("%d-%d", label.ID, label.Updated.UnixNano())
if in.HasConditionalParams() {
if err := in.PreconditionFailed(etag, label.Updated); err != nil {
return nil, err
}
}
return &singleReadBody[models.Label]{ETag: `"` + etag + `"`, Body: label}, nil
}
func labelsCreate(ctx context.Context, in *struct {
Body models.Label
}) (*singleBody[models.Label], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.Label]{Body: &in.Body}, nil
}
func labelsUpdate(ctx context.Context, in *struct {
ID int64 `path:"id"`
Body models.Label
}) (*singleBody[models.Label], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
in.Body.ID = in.ID // URL wins over body
if err := handler.DoUpdate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.Label]{Body: &in.Body}, nil
}
func labelsDelete(ctx context.Context, in *struct {
ID int64 `path:"id"`
}) (*emptyBody, error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
if err := handler.DoDelete(ctx, &models.Label{ID: in.ID}, a); err != nil {
return nil, translateDomainError(err)
}
return &emptyBody{}, nil
}

View File

@ -0,0 +1,66 @@
// 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 apiv2
// Paginated is the standard list-response envelope for every /api/v2 list operation.
type Paginated[T any] struct {
Items []T `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int64 `json:"total_pages"`
}
// NewPaginated builds a Paginated envelope. Nil items become an empty
// slice so the JSON response is [] rather than null.
func NewPaginated[T any](items []T, total int64, page, perPage int) Paginated[T] {
if items == nil {
items = []T{}
}
var totalPages int64
if perPage > 0 {
totalPages = (total + int64(perPage) - 1) / int64(perPage)
}
return Paginated[T]{
Items: items,
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}
}
// ListParams carries the standard (page, per_page, q) query shape for list operations.
type ListParams struct {
Page int `query:"page" default:"1" minimum:"1"`
PerPage int `query:"per_page" default:"50" minimum:"1" maximum:"1000"`
Q string `query:"q"`
}
// singleBody is the create/update response envelope (no ETag).
type singleBody[T any] struct {
Body *T
}
// singleReadBody is the read response envelope; carries ETag for If-None-Match.
type singleReadBody[T any] struct {
ETag string `header:"ETag"`
Body *T
}
// emptyBody marks delete / no-content operations.
type emptyBody struct{}

View File

@ -39,6 +39,12 @@ func SetupTokenMiddleware() echo.MiddlewareFunc {
return echojwt.WithConfig(echojwt.Config{
SigningKey: []byte(config.ServiceSecret.GetString()),
Skipper: func(c *echo.Context) bool {
// Public routes (docs, spec, info, etc.) never need JWT even
// when their parent group has the middleware applied.
if unauthenticatedAPIPaths[c.Path()] {
return true
}
authHeader := c.Request().Header.Values("Authorization")
if len(authHeader) == 0 {
return false // let the jwt middleware handle invalid headers

View File

@ -338,6 +338,8 @@ var unauthenticatedAPIPaths = map[string]bool{
"/api/v2/openapi.json": true,
"/api/v2/openapi.yaml": true,
"/api/v2/openapi-3.0.json": true,
"/api/v2/openapi-3.0.yaml": true,
"/api/v2/docs": true,
"/api/v2/docs/scalar.standalone.js": true,
}
@ -373,12 +375,21 @@ func noStoreCacheControl() echo.MiddlewareFunc {
}
}
// registerAPIRoutesV2 wires the /api/v2 Echo group. Huma and per-resource
// route registrations land here in later sub-phases.
// registerAPIRoutesV2 wires the /api/v2 Echo group. Token middleware is
// attached before any route so Huma's spec and Scalar docs share the
// resource handlers' stack; unauthenticatedAPIPaths keeps them public.
func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
a.Use(noStoreCacheControl())
_ = apiv2.NewAPI(e, a)
// Resource registrations go here in later sub-phases.
a.Use(SetupTokenMiddleware())
api := apiv2.NewAPI(e, a)
// Resource registrations.
apiv2.RegisterLabelRoutes(api)
// AutoPatch must run AFTER all GET/PUT pairs are registered so it can
// synthesize their PATCH counterparts.
apiv2.EnableAutoPatch(api)
}
func registerAPIRoutes(a *echo.Group) {