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
parent d721706dd9
commit cc2c20e6aa
3 changed files with 232 additions and 3 deletions

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

@@ -0,0 +1,212 @@
// 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/modules/auth"
"code.vikunja.io/api/pkg/web/handler"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/conditional"
)
// Paginated is the standard list-response envelope for /api/v2.
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"`
}
// --- Label ---
// labelBody is the standard single-Label response envelope (no cache headers).
type labelBody struct {
Body *models.Label
}
// labelReadBody is the read-operation response envelope, carrying an ETag
// header so clients can issue If-None-Match for subsequent reads.
type labelReadBody struct {
ETag string `header:"ETag"`
Body *models.Label
}
type labelListBody struct {
Body Paginated[*models.Label]
}
type emptyBody struct{}
// jwtSecurity is the security requirement entry applied to every Label
// operation. Mirrors the "JWTKeyAuth" scheme declared in huma.go.
var jwtSecurity = []map[string][]string{{"JWTKeyAuth": {}}}
// RegisterLabelRoutes wires Label CRUD operations onto the given Huma API.
func RegisterLabelRoutes(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "labels-list",
Method: http.MethodGet,
Path: "/labels",
Tags: []string{"labels"},
Security: jwtSecurity,
}, labelsList)
huma.Register(api, huma.Operation{
OperationID: "labels-read",
Method: http.MethodGet,
Path: "/labels/{id}",
Tags: []string{"labels"},
Security: jwtSecurity,
}, labelsRead)
huma.Register(api, huma.Operation{
OperationID: "labels-create",
Method: http.MethodPost,
Path: "/labels",
Tags: []string{"labels"},
Security: jwtSecurity,
DefaultStatus: http.StatusCreated,
}, labelsCreate)
huma.Register(api, huma.Operation{
OperationID: "labels-update",
Method: http.MethodPut,
Path: "/labels/{id}",
Tags: []string{"labels"},
Security: jwtSecurity,
}, labelsUpdate)
huma.Register(api, huma.Operation{
OperationID: "labels-delete",
Method: http.MethodDelete,
Path: "/labels/{id}",
Tags: []string{"labels"},
Security: jwtSecurity,
DefaultStatus: http.StatusNoContent,
}, labelsDelete)
}
// --- handlers ---
func labelsList(ctx context.Context, in *struct {
Page int `query:"page" default:"1" minimum:"1"`
PerPage int `query:"per_page" default:"50" minimum:"1" maximum:"1000"`
Q string `query:"q"`
}) (*labelListBody, error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
result, _, total, err := handler.DoReadAll(ctx, &models.Label{}, a, in.Q, in.Page, in.PerPage)
if err != nil {
return nil, err
}
// Concrete type cast — prevents the generic-any silent-empty trap the
// spike hit, where an `interface{}` slice marshalled to an empty JSON
// array without a loud failure.
items, ok := result.([]*models.Label)
if !ok {
return nil, fmt.Errorf("labels.ReadAll returned unexpected type %T (expected []*models.Label)", result)
}
if items == nil {
items = []*models.Label{}
}
totalPages := int64(0)
if in.PerPage > 0 {
totalPages = (total + int64(in.PerPage) - 1) / int64(in.PerPage)
}
return &labelListBody{Body: Paginated[*models.Label]{
Items: items,
Total: total,
Page: in.Page,
PerPage: in.PerPage,
TotalPages: totalPages,
}}, nil
}
func labelsRead(ctx context.Context, in *struct {
ID int64 `path:"id"`
conditional.Params
}) (*labelReadBody, error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
label := &models.Label{ID: in.ID}
if _, err := handler.DoReadOne(ctx, label, a); err != nil {
return nil, err
}
// ETag derives from the ID + last-updated timestamp so any edit
// invalidates downstream caches. RFC 9110 requires the quoted form.
etag := fmt.Sprintf(`"%d-%d"`, label.ID, label.Updated.UnixNano())
if in.HasConditionalParams() {
// PreconditionFailed returns a 304 (reads) or 412 (writes) when
// conditions aren't met; nil means continue.
if err := in.PreconditionFailed(etag, label.Updated); err != nil {
return nil, err
}
}
return &labelReadBody{ETag: etag, Body: label}, nil
}
func labelsCreate(ctx context.Context, in *struct {
Body models.Label
}) (*labelBody, 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, err
}
return &labelBody{Body: &in.Body}, nil
}
func labelsUpdate(ctx context.Context, in *struct {
ID int64 `path:"id"`
Body models.Label
}) (*labelBody, error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
in.Body.ID = in.ID // URL wins over body
if err := handler.DoUpdate(ctx, &in.Body, a); err != nil {
return nil, err
}
return &labelBody{Body: &in.Body}, nil
}
func labelsDelete(ctx context.Context, in *struct {
ID int64 `path:"id"`
}) (*emptyBody, error) {
a, err := auth.GetAuthFromContext(ctx)
if err != nil {
return nil, huma.Error401Unauthorized(err.Error())
}
if err := handler.DoDelete(ctx, &models.Label{ID: in.ID}, a); err != nil {
return nil, err
}
return &emptyBody{}, nil
}

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

@@ -327,6 +327,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,
}
@@ -350,15 +352,24 @@ func collectRoutesForAPITokens(e *echo.Echo) {
}
// registerAPIRoutesV2 wires the /api/v2 Echo group. Huma and per-resource
// route registrations land here in later sub-phases.
// route registrations land here.
//
// The JWT middleware is attached before any route registration so Huma's
// own spec routes (/openapi.{json,yaml}) and our Scalar docs pages are
// covered by the same middleware stack as the resource handlers. Routes
// listed in `unauthenticatedAPIPaths` are skipped by `SetupTokenMiddleware`
// so the spec + docs remain public.
func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
_ = apiv2.NewAPI(e, a)
a.Use(SetupTokenMiddleware())
api := apiv2.NewAPI(e, a)
// Scalar docs UI — embedded, no CDN. See pkg/routes/api/v2/docs.go.
a.GET("/docs", apiv2.ScalarUI)
a.GET("/docs/scalar.standalone.js", apiv2.ScalarJS)
// Resource registrations go here in later sub-phases.
// Resource registrations.
apiv2.RegisterLabelRoutes(api)
}
func registerAPIRoutes(a *echo.Group) {