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:
parent
fd2f005a3b
commit
0a7750ee3d
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue