From 0a7750ee3d5f79ffd89388a8eaa83f1eff8a5d36 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 3 Jun 2026 19:17:15 +0200 Subject: [PATCH] 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. --- pkg/routes/api/v2/projects.go | 182 ++++++++++++++++++++++++++++ pkg/webtests/huma_project_test.go | 194 ++++++++++++++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 pkg/routes/api/v2/projects.go create mode 100644 pkg/webtests/huma_project_test.go diff --git a/pkg/routes/api/v2/projects.go b/pkg/routes/api/v2/projects.go new file mode 100644 index 000000000..c8e45018d --- /dev/null +++ b/pkg/routes/api/v2/projects.go @@ -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 . + +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 +} diff --git a/pkg/webtests/huma_project_test.go b/pkg/webtests/huma_project_test.go new file mode 100644 index 000000000..da820e3f8 --- /dev/null +++ b/pkg/webtests/huma_project_test.go @@ -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 . + +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) + }) + }) + }) +}