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:
parent
21ce33f8fd
commit
466d39e6de
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue