mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-05-21 10:41:50 -05:00
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:
212
pkg/routes/api/v2/labels.go
Normal file
212
pkg/routes/api/v2/labels.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user