From a2156e72318dfdf1d26b645b4343d39f79ad09a7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 21 Apr 2026 13:13:34 +0200 Subject: [PATCH] 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. --- pkg/routes/api/v2/errors.go | 57 +++++++++++++ pkg/routes/api/v2/huma.go | 27 ++++++- pkg/routes/api/v2/labels.go | 155 ++++++++++++++++++++++++++++++++++++ pkg/routes/api/v2/types.go | 66 +++++++++++++++ pkg/routes/api_tokens.go | 6 ++ pkg/routes/routes.go | 19 ++++- 6 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 pkg/routes/api/v2/errors.go create mode 100644 pkg/routes/api/v2/labels.go create mode 100644 pkg/routes/api/v2/types.go diff --git a/pkg/routes/api/v2/errors.go b/pkg/routes/api/v2/errors.go new file mode 100644 index 000000000..b7e266e29 --- /dev/null +++ b/pkg/routes/api/v2/errors.go @@ -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 . + +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 +} diff --git a/pkg/routes/api/v2/huma.go b/pkg/routes/api/v2/huma.go index 9e9f5671b..9d2e44556 100644 --- a/pkg/routes/api/v2/huma.go +++ b/pkg/routes/api/v2/huma.go @@ -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) +} diff --git a/pkg/routes/api/v2/labels.go b/pkg/routes/api/v2/labels.go new file mode 100644 index 000000000..9dc77e076 --- /dev/null +++ b/pkg/routes/api/v2/labels.go @@ -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 . + +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 +} diff --git a/pkg/routes/api/v2/types.go b/pkg/routes/api/v2/types.go new file mode 100644 index 000000000..bb79d9014 --- /dev/null +++ b/pkg/routes/api/v2/types.go @@ -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 . + +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{} diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go index 0e29911c9..0c9708849 100644 --- a/pkg/routes/api_tokens.go +++ b/pkg/routes/api_tokens.go @@ -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 diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index aa1b06188..511547494 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -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) {