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.
This commit is contained in:
Tink bot 2026-05-19 07:46:58 +00:00 committed by kolaente
parent 21ce33f8fd
commit 466d39e6de
4 changed files with 142 additions and 3 deletions

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}
}
}

View File

@ -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)

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
})
}