From 466d39e6de1bee7d7555aabe8162050a35859c07 Mon Sep 17 00:00:00 2001 From: Tink bot Date: Tue, 19 May 2026 07:46:58 +0000 Subject: [PATCH] feat(api): accept project identifier in by-index task route Allows GET /projects/{project}/tasks/by-index/{index} to resolve {project} as either a numeric id or a project identifier (e.g. "PROJ"), so callers can build GitHub-style task references like "PROJ-42" without first looking up the project's numeric id. Pure-digit values remain interpreted as ids, which makes identifiers consisting solely of digits unreachable via this route. --- pkg/routes/api/v1/task_by_index.go | 4 +- pkg/routes/resolve_project.go | 68 ++++++++++++++++++++++++++++ pkg/routes/routes.go | 2 +- pkg/webtests/task_by_index_test.go | 71 ++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 pkg/routes/resolve_project.go create mode 100644 pkg/webtests/task_by_index_test.go diff --git a/pkg/routes/api/v1/task_by_index.go b/pkg/routes/api/v1/task_by_index.go index 49106a311..19828bae4 100644 --- a/pkg/routes/api/v1/task_by_index.go +++ b/pkg/routes/api/v1/task_by_index.go @@ -22,11 +22,11 @@ package v1 // taskHandler.ReadOneWeb in routes.go. // // @Summary Get one task by its per-project index -// @Description Returns a single task identified by its per-project index. Useful when resolving human-readable references like "PROJ-42" to a canonical task object. Note that task indexes are reassigned when a task is moved between projects, so long-lived references should use the returned task id instead. +// @Description Returns a single task identified by its per-project index. Useful when resolving human-readable references like "PROJ-42" to a canonical task object. The `project` path parameter accepts either a numeric project id or the project's identifier (e.g. "PROJ"); values consisting solely of digits are always interpreted as ids. Note that task indexes are reassigned when a task is moved between projects, so long-lived references should use the returned task id instead. // @tags task // @Accept json // @Produce json -// @Param project path int true "The project ID" +// @Param project path string true "The project id or the project's identifier" // @Param index path int true "The task's per-project index" // @Param expand query string false "If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a second step, will fetch all of these subtasks. This may result in more tasks than the pagination limit being returned, but all subtasks will be present in the response. You can only set this to `subtasks`." // @Security JWTKeyAuth diff --git a/pkg/routes/resolve_project.go b/pkg/routes/resolve_project.go new file mode 100644 index 000000000..26ec83341 --- /dev/null +++ b/pkg/routes/resolve_project.go @@ -0,0 +1,68 @@ +// 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 routes + +import ( + "net/http" + "strconv" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/labstack/echo/v5" +) + +// ResolveProjectIdentifier accepts either a numeric project id or a project +// identifier (e.g. "PROJ") in the :project path param and rewrites it to the +// numeric id so downstream handlers can bind it as an int64. Pure-digit values +// are always treated as ids, which means identifiers consisting solely of +// digits are unreachable via this route. +func ResolveProjectIdentifier() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + raw := c.Param("project") + if raw == "" { + return next(c) + } + if _, err := strconv.ParseInt(raw, 10, 64); err == nil { + return next(c) + } + + s := db.NewSession() + project := &models.Project{} + has, err := s.Where("identifier = ?", raw).Get(project) + _ = s.Close() + if err != nil { + return err + } + if !has { + return echo.NewHTTPError(http.StatusNotFound, "Project not found") + } + + values := c.PathValues() + for i, v := range values { + if v.Name == "project" { + values[i].Value = strconv.FormatInt(project.ID, 10) + break + } + } + c.SetPathValues(values) + + return next(c) + } + } +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index c4ce0a002..353a1b750 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -557,7 +557,7 @@ func registerAPIRoutes(a *echo.Group) { } a.PUT("/projects/:project/tasks", taskHandler.CreateWeb) a.GET("/tasks/:projecttask", taskHandler.ReadOneWeb) - a.GET("/projects/:project/tasks/by-index/:index", taskHandler.ReadOneWeb) + a.GET("/projects/:project/tasks/by-index/:index", taskHandler.ReadOneWeb, ResolveProjectIdentifier()) a.GET("/tasks", taskCollectionHandler.ReadAllWeb) a.DELETE("/tasks/:projecttask", taskHandler.DeleteWeb) a.POST("/tasks/:projecttask", taskHandler.UpdateWeb) diff --git a/pkg/webtests/task_by_index_test.go b/pkg/webtests/task_by_index_test.go new file mode 100644 index 000000000..dac04fbe4 --- /dev/null +++ b/pkg/webtests/task_by_index_test.go @@ -0,0 +1,71 @@ +// 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/http/httptest" + "testing" + + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTaskByProjectIndex(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + token, err := auth.NewUserJWTAuthtoken(&testuser1, "test-session-id") + require.NoError(t, err) + + do := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec + } + + t.Run("by numeric project id", func(t *testing.T) { + rec := do("/api/v1/projects/1/tasks/by-index/1") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"id":1`) + }) + + t.Run("by project identifier", func(t *testing.T) { + // Project 1 has identifier "test1" in fixtures. + rec := do("/api/v1/projects/test1/tasks/by-index/1") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"id":1`) + }) + + t.Run("unknown project identifier returns 404", func(t *testing.T) { + rec := do("/api/v1/projects/does-not-exist/tasks/by-index/1") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("numeric-only value always treated as id", func(t *testing.T) { + // Even if a project had the identifier "999999", a pure-digit value + // is parsed as an id; the task lookup then fails. + rec := do("/api/v1/projects/999999/tasks/by-index/1") + assert.NotEqual(t, http.StatusOK, rec.Code) + }) +}