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) {