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:
parent
b52a451db4
commit
a2156e7231
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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{}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue