feat(api/v2): add Project CRUD on /api/v2

Add a simple /{id} CRUD resource for projects on the Huma-backed /api/v2,
mirroring labels.go. Exposes the expand query param (value "permissions")
which surfaces the caller's max permission per project on both list and read.
The handler stays standard (DoReadAll/DoReadOne/DoCreate/DoUpdate/DoDelete);
the model's ReadOne keeps handling the Favorites pseudo-project and
saved-filter-backed projects.

Self-registers via init() -> AddRouteRegistrar; no routes.go change.
projectusers is intentionally out of scope.
This commit is contained in:
kolaente 2026-06-03 19:17:15 +02:00 committed by kolaente
parent fd2f005a3b
commit 0a7750ee3d
2 changed files with 376 additions and 0 deletions

View File

@ -0,0 +1,182 @@
// 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"
)
// projectListBody is the list-response envelope. models.Project.ReadAll
// returns []*models.Project, so that's the element type.
type projectListBody struct {
Body Paginated[*models.Project]
}
// RegisterProjectRoutes wires Project CRUD onto the Huma API.
func RegisterProjectRoutes(api huma.API) {
tags := []string{"projects"}
Register(api, huma.Operation{
OperationID: "projects-list",
Summary: "List projects",
Description: "Returns the projects the authenticated user has access to (owned plus shared, with child projects of accessible parents), paginated. Archived projects are excluded unless is_archived=true. Pass expand=permissions to include each project's max_permission for the caller.",
Method: http.MethodGet,
Path: "/projects",
Tags: tags,
}, projectsList)
Register(api, huma.Operation{
OperationID: "projects-read",
Summary: "Get a project",
Description: "Returns a single project the caller can read, including its views and the caller's favorite/subscription state. Resolves the Favorites pseudo-project and saved-filter-backed projects. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.",
Method: http.MethodGet,
Path: "/projects/{id}",
Tags: tags,
}, projectsRead)
Register(api, huma.Operation{
OperationID: "projects-create",
Summary: "Create a project",
Description: "Creates a project; the authenticated user becomes its owner. When parent_project_id is set, the caller needs write access to that parent. Default views and a backlog bucket are created automatically.",
Method: http.MethodPost,
Path: "/projects",
Tags: tags,
}, projectsCreate)
Register(api, huma.Operation{
OperationID: "projects-update",
Summary: "Update a project",
Description: "Replaces a project's fields. Requires write access (admin to reparent or delete). Use PATCH for a partial update.",
Method: http.MethodPut,
Path: "/projects/{id}",
Tags: tags,
}, projectsUpdate)
Register(api, huma.Operation{
OperationID: "projects-delete",
Summary: "Delete a project",
Description: "Deletes a project together with its tasks, views, buckets and child projects. Only project admins may delete it.",
Method: http.MethodDelete,
Path: "/projects/{id}",
Tags: tags,
}, projectsDelete)
}
func init() { AddRouteRegistrar(RegisterProjectRoutes) }
func projectsList(ctx context.Context, in *struct {
ListParams
Expand string `query:"expand" enum:"permissions" doc:"If set to \"permissions\", each returned project includes the max permission the requesting user has on it (max_permission). Currently only \"permissions\" is supported."`
IsArchived bool `query:"is_archived" doc:"If true, also returns archived projects."`
}) (*projectListBody, error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
p := &models.Project{
Expand: models.ProjectExpandable(in.Expand),
IsArchived: in.IsArchived,
}
result, _, total, err := handler.DoReadAll(ctx, p, a, in.Q, in.Page, in.PerPage)
if err != nil {
return nil, translateDomainError(err)
}
items, ok := result.([]*models.Project)
if !ok {
return nil, fmt.Errorf("projects.ReadAll returned unexpected type %T (expected []*models.Project)", result)
}
return &projectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
}
func projectsRead(ctx context.Context, in *struct {
ID int64 `path:"id"`
Expand string `query:"expand" enum:"permissions" doc:"If set to \"permissions\", the project includes the max permission the requesting user has on it (max_permission)."`
conditional.Params
}) (*singleReadBody[models.Project], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
project := &models.Project{ID: in.ID, Expand: models.ProjectExpandable(in.Expand)}
maxPermission, err := handler.DoReadOne(ctx, project, a)
if err != nil {
return nil, translateDomainError(err)
}
// ReadOne doesn't act on Expand itself; the caller's max permission comes
// from DoReadOne's CanRead result. Surface it on the model only when asked,
// matching the list operation's expand=permissions behaviour.
if models.ProjectExpandable(in.Expand) == models.ProjectExpandableRights {
project.MaxPermission = models.Permission(maxPermission)
}
// PreconditionFailed wants the unquoted etag; response header uses RFC 9110 quoted form.
etag := fmt.Sprintf("%d-%d", project.ID, project.Updated.UnixNano())
if in.HasConditionalParams() {
if err := in.PreconditionFailed(etag, project.Updated); err != nil {
return nil, err
}
}
return &singleReadBody[models.Project]{ETag: `"` + etag + `"`, Body: project}, nil
}
func projectsCreate(ctx context.Context, in *struct {
Body models.Project
}) (*singleBody[models.Project], 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.Project]{Body: &in.Body}, nil
}
func projectsUpdate(ctx context.Context, in *struct {
ID int64 `path:"id"`
Body models.Project
}) (*singleBody[models.Project], 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.Project]{Body: &in.Body}, nil
}
func projectsDelete(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.Project{ID: in.ID}, a); err != nil {
return nil, translateDomainError(err)
}
return &emptyBody{}, nil
}

View File

@ -0,0 +1,194 @@
// 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 webtests
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestHumaProject mirrors v1's TestProject shape so v2 contract parity is
// readable side-by-side. Status-code differences from v1 are noted inline.
func TestHumaProject(t *testing.T) {
testHandler := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects",
idParam: "project",
t: t,
}
t.Run("ReadAll", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(nil, nil)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `Test1`)
assert.NotContains(t, rec.Body.String(), `Test2"`)
assert.Contains(t, rec.Body.String(), `Test3`) // Shared directly via users_project
assert.Contains(t, rec.Body.String(), `Test12`) // Shared via parent project
assert.NotContains(t, rec.Body.String(), `Test5`)
assert.NotContains(t, rec.Body.String(), `Test21`) // Archived through parent project
assert.NotContains(t, rec.Body.String(), `Test22`) // Archived directly
})
t.Run("Normal with archived projects", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"is_archived": []string{"true"}}, nil)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `Test1`)
assert.Contains(t, rec.Body.String(), `Test21`) // Archived through project
assert.Contains(t, rec.Body.String(), `Test22`) // Archived directly
})
t.Run("Expand permissions", func(t *testing.T) {
rec, err := testHandler.testReadAllWithUser(url.Values{"expand": []string{"permissions"}}, nil)
require.NoError(t, err)
// User 1 owns Test1 → admin (2). With expand the field carries a real value.
assert.Contains(t, rec.Body.String(), `"max_permission":2`)
})
})
t.Run("ReadOne", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "1"})
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Test1"`)
assert.NotContains(t, rec.Body.String(), `"title":"Test2"`)
assert.Contains(t, rec.Body.String(), `"username":"user1"`)
assert.NotEmpty(t, rec.Result().Header.Get("ETag"))
})
t.Run("Expand permissions", func(t *testing.T) {
// User 1 owns Test1 → admin (2); expand surfaces it as max_permission.
rec, err := testHandler.testReadOneWithUser(url.Values{"expand": []string{"permissions"}}, map[string]string{"project": "1"})
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"max_permission":2`)
})
t.Run("Nonexisting", func(t *testing.T) {
// Projects return 404 here (CanRead → GetProjectSimpleByID → ErrProjectDoesNotExist),
// unlike labels which return 403 from the read branch.
_, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "9999"})
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
t.Run("Permissions check", func(t *testing.T) {
t.Run("Forbidden", func(t *testing.T) {
// Project 20 exists but is owned by user13: CanRead returns false → 403.
_, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "20"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Shared Via Team readonly", func(t *testing.T) {
rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "6"})
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Test6"`)
})
t.Run("Shared Via User admin", func(t *testing.T) {
rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "11"})
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Test11"`)
})
})
})
t.Run("Create", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, nil, `{"title":"Lorem","description":"Ipsum"}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"title":"Lorem"`)
assert.Contains(t, rec.Body.String(), `"description":"Ipsum"`)
})
t.Run("Empty title", func(t *testing.T) {
// v2 returns 422, not v1's 400; full body shape asserted in TestHuma_ErrorShapeIsRFC9457.
_, err := testHandler.testCreateWithUser(nil, nil, `{"title":""}`)
require.Error(t, err)
assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
})
})
t.Run("Update", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "1"}, `{"title":"TestLoremIpsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`)
// The description should not be wiped but returned as it was.
assert.Contains(t, rec.Body.String(), `"description":"Lorem Ipsum`)
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "9999"}, `{"title":"TestLoremIpsum"}`)
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
t.Run("Empty title", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "1"}, `{"title":""}`)
require.Error(t, err)
assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
})
t.Run("Permissions check", func(t *testing.T) {
t.Run("Forbidden", func(t *testing.T) {
// Owned by user13.
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "20"}, `{"title":"TestLoremIpsum"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Shared Via Team readonly forbidden", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "6"}, `{"title":"TestLoremIpsum"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Shared Via Team write allowed", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "7"}, `{"title":"TestLoremIpsum"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`)
})
})
})
t.Run("Delete", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "1"})
require.NoError(t, err)
// v2 delete is 204 No Content; v1 returned 200 + a message body.
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Empty(t, rec.Body.String())
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "999"})
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
t.Run("Permissions check", func(t *testing.T) {
t.Run("Forbidden", func(t *testing.T) {
// Owned by user13.
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "20"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Shared Via Team write forbidden", func(t *testing.T) {
// Write access is not enough to delete; needs admin.
_, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "7"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Shared Via Team admin allowed", func(t *testing.T) {
rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "8"})
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
})
})
})
}