diff --git a/apis/project.go b/apis/project.go index b1308e1..656d07a 100644 --- a/apis/project.go +++ b/apis/project.go @@ -1,223 +1,264 @@ -// SPDX-FileCopyrightText: 2017-2019 Harald Sitter +// SPDX-FileCopyrightText: 2017-2020 Harald Sitter // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL package apis import ( "net/http" "strings" "invent.kde.org/sysadmin/projects-api.git/models" "github.com/gin-gonic/gin" ) type projectService interface { Get(path string) (*models.Project, error) + GetByIdentifier(id string) (*models.Project, error) Find(filter *models.ProjectFilter) ([]string, error) List(path string) ([]string, error) } type projectResource struct { service projectService } // ServeProjectResource creates a new API resource. func ServeProjectResource(rg *gin.RouterGroup, service projectService) { r := &projectResource{service} rg.GET("/repo/*path", r.repo) rg.GET("/project/*path", r.get) + rg.GET("/identifier/*id", r.getByIdentifier) rg.GET("/find", r.find) rg.GET("/projects", r.projects) rg.GET("/projects/*path", r.projects) } /** * @apiDefine ProjectSuccessExample * @apiSuccessExample {json} Success-Response: * { * "i18n": { * "stable": "none", * "stableKF5": "krita/3.1", * "trunk": "none", * "trunkKF5": "master", * "component": "calligra" * }, * "repo": "krita" * "path": "calligra/krita" * } */ /** - * @api {get} /project/:path Get (by Project) + * @api {get} /project/:path Get (by Project Path) * * @apiVersion 1.0.0 * @apiGroup Project * @apiName project * * @apiDescription Gets the metadata of the project identified by path. * * @apiUse ProjectSuccessExample * * @apiError Forbidden Path may not be accessed. */ func (r *projectResource) get(c *gin.Context) { path := strings.TrimLeft(c.Param("path"), "/") if strings.Contains(path, "/..") || strings.Contains(path, "../") { c.AbortWithStatus(http.StatusForbidden) return } response, err := r.service.Get(path) if err != nil { panic(err) } if response == nil { c.AbortWithStatus(http.StatusNotFound) return } c.JSON(http.StatusOK, response) } +/** + * @api {get} /identifier/:id Get (by identifier) + * + * @apiVersion 1.0.0 + * @apiGroup Project + * @apiName project + * + * @apiDescription Gets the metadata of the project identified by identifier. + * + * @apiUse ProjectSuccessExample + * + * @apiError NotFound identifier is not known to be associated with any project + */ +func (r *projectResource) getByIdentifier(c *gin.Context) { + id := strings.TrimLeft(c.Param("id"), "/") + + response, err := r.service.GetByIdentifier(id) + if err != nil { + panic(err) + } + + if response == nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + + c.JSON(http.StatusOK, response) +} + /** * @apiDeprecated use the /project endpoint. Since the gitlab migration the * repo path and the project path are the same. * @api {get} /repo/:path Get (by Repository) * @apiParam {String} path Repository path as seen in git clone kde:path/is/this. * * @apiVersion 1.0.0 * @apiGroup Project * @apiName repo * * @apiDescription Gets the metadata of the project identified by a query. * * @apiUse ProjectSuccessExample * * @apiError NotFound Couldn't find a project associated with this repo. * @apiError MultipleChoices Couldn't find a unique project for this repo. * You'll need to pick a project and get it via the /project/ * endpoint * @apiErrorExample {json} MultipleChoices-Response: * [ * "books/kf5book", * "books/kf5book-duplcate" * ] */ func (r *projectResource) repo(c *gin.Context) { path := strings.TrimLeft(c.Param("path"), "/") matches, err := r.service.Find(&models.ProjectFilter{RepoPath: path}) if err != nil { panic(err) } if len(matches) < 1 { c.AbortWithStatus(http.StatusNotFound) return } if len(matches) > 1 { c.JSON(http.StatusMultipleChoices, matches) return } response, err := r.service.Get(matches[0]) if err != nil { panic(err) } c.JSON(http.StatusOK, response) } /** * @api {get} /find Find - * @apiParam {String} i18n_component I18n component of the project to find. - * Since the gitlab transition this acts as unique project identifier. - * This is the i18n{component:foo} value returned by the /project endpoint. * @apiParam {String} repo repo attribute of the project * to find. * @apiParam {Bool} active Whether to find only projects marked active (inactive * projects can be skipped by code that wants to iterate worthwhile code * projects only e.g. sysadmin repos are usually inactive). * This defaults to false, giving you all repos. * @apiParam {Bool} inactive Whether to find only inactive projects (see active) * This defaults to false, giving you all repos. - * @apiParam {String} id Basename of the project to find. Since the gitlab + * @apiParam {String} basename Basename of the project to find. Since the gitlab * migration this filter doesn't have much purpose since the basenames are * not unique. Do not assume that you'll get a unique result when using this - * filter! + * filter! This used to be called `id`. * * @apiVersion 1.0.0 * @apiGroup Project * @apiName find * * @apiDescription Finds matching projects by a combination of filter params or * none to list all projects. * * @apiError NotFound Nothing matched the filter criteria * * @apiSuccessExample {json} Success-Response: * [ * "books", * "books/kf5book", * "calligra", * ... * ] */ func (r *projectResource) find(c *gin.Context) { var filter models.ProjectFilter c.BindQuery(&filter) + + // backwards compat handling: id is now called basename + if len(filter.ID) != 0 { + if len(filter.Basename) != 0 { + c.String(http.StatusConflict, + "Query must not contain `basename` and its legacy variant `id` at the same time.\n") + c.AbortWithStatus(http.StatusConflict) + return + } + filter.Basename = filter.ID + } + matches, err := r.service.Find(&filter) if len(matches) == 0 || err != nil { c.AbortWithStatus(http.StatusNotFound) return } + c.JSON(http.StatusOK, matches) } /** * @api {get} /projects/:prefix List * @apiParam {String} prefix Prefix path of the project. This will usually be * the module/components the project is sorted under. * * @apiVersion 1.0.0 * @apiGroup Project * @apiName projects * * @apiDescription Lists all *projects* underneath the prefix. If the prefix is * a project itself it will be included in the list. If the prefix is not a * project only its "children" will be returned. * * @apiSuccessExample {json} Project (/projects/calligra/krita): * [ * "calligra/krita" * ] * * @apiSuccessExample {json} Project with Children (/projects/papa): * [ * "aba", * "aba/bubbale1", * "aba/bubbale2" * ] * * @apiSuccessExample {json} Component (/projects/frameworks) * [ * "frameworks/solid", * "frameworks/ki18n", * "..." * ] */ func (r *projectResource) projects(c *gin.Context) { path := strings.TrimLeft(c.Param("path"), "/") matches, err := r.service.List(path) if len(matches) == 0 || err != nil { c.AbortWithStatus(http.StatusNotFound) return } c.JSON(http.StatusOK, matches) } diff --git a/apis/project_test.go b/apis/project_test.go index 1332e83..a595f7e 100644 --- a/apis/project_test.go +++ b/apis/project_test.go @@ -1,82 +1,99 @@ -// SPDX-FileCopyrightText: 2017-2019 Harald Sitter +// SPDX-FileCopyrightText: 2017-2020 Harald Sitter // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL package apis import ( "errors" "net/http" "testing" "invent.kde.org/sysadmin/projects-api.git/models" ) // Test Double type ProjectService struct { } func NewProjectService() *ProjectService { return &ProjectService{} } func (s *ProjectService) Get(path string) (*models.Project, error) { project := models.Project{} if path == "calligra/krita" { project.Path = "calligra/krita" project.Repo = "krita" return &project, nil } if path == "calligra" { return nil, nil } return &project, errors.New("unexpected path " + path) } +func (s *ProjectService) GetByIdentifier(id string) (*models.Project, error) { + project := models.Project{} + if id == "kritaid" { + project.Path = "calligra/krita" + project.Repo = "krita" + project.Identifier = "kritaid" + return &project, nil + } + if id == "foo" { + return nil, nil + } + return &project, errors.New("unexpected id " + id) +} + func (s *ProjectService) Find(filter *models.ProjectFilter) ([]string, error) { projects := []string{"calligra/krita"} - if filter.ID == "krita" && filter.RepoPath == "" { + if filter.Basename == "krita" && filter.RepoPath == "" { return projects, nil } - if filter.ID == "" && filter.RepoPath == "krita" { + if filter.Basename == "" && filter.RepoPath == "krita" { return projects, nil } - if filter.ID == "" && filter.InactiveOnly { + if filter.Basename == "" && filter.InactiveOnly { return []string{"kde/hole"}, nil } - if filter.ID == "" && filter.RepoPath == "" { + if filter.Basename == "" && filter.RepoPath == "" { return append(projects, "frameworks/solid"), nil } - panic("unexpected query " + filter.ID + " " + filter.RepoPath) + panic("unexpected query " + filter.Basename + " " + filter.RepoPath) } func (s *ProjectService) List(path string) ([]string, error) { if path == "" { return []string{"calligra/krita", "frameworks/solid"}, nil } if path == "calligra" { return []string{"calligra/krita"}, nil } panic("unexpected query " + path) } func init() { v1 := router.Group("/v1") { ServeProjectResource(v1, NewProjectService()) } } func TestProject(t *testing.T) { kritaObj := `{"i18n":{"trunkKF5":"", "component":"", "stable":"", "stableKF5":"", "trunk":""}, "repo":"krita", "path":"calligra/krita"}` runAPITests(t, []apiTestCase{ {"get a project", "GET", "/v1/project/calligra/krita", "", http.StatusOK, kritaObj}, {"get nil project", "GET", "/v1/project/calligra", "", http.StatusNotFound, ""}, + {"get by id", "GET", "/v1/identifier/kritaid", "", http.StatusOK, kritaObj}, + {"get by bad id", "GET", "/v1/identifier/foo", "", http.StatusNotFound, ""}, {"get by repopath", "GET", "/v1/repo/krita", "", http.StatusOK, kritaObj}, {"find by id", "GET", "/v1/find?id=krita", "", http.StatusOK, `["calligra/krita"]`}, {"find by repopath", "GET", "/v1/find?repo=krita", "", http.StatusOK, `["calligra/krita"]`}, {"find by only inactive", "GET", "/v1/find?inactive=true", "", http.StatusOK, `["kde/hole"]`}, + {"find conflicting basename and id", "GET", "/v1/find?id=a&basename=b", "", http.StatusConflict, ""}, {"find all", "GET", "/v1/find", "", http.StatusOK, `["calligra/krita", "frameworks/solid"]`}, {"list", "GET", "/v1/projects", "", http.StatusOK, `["calligra/krita", "frameworks/solid"]`}, {"list component", "GET", "/v1/projects/calligra", "", http.StatusOK, `["calligra/krita"]`}, }) } diff --git a/models/project_filter.go b/models/project_filter.go index a2d2aba..1e0a4c5 100644 --- a/models/project_filter.go +++ b/models/project_filter.go @@ -1,15 +1,15 @@ // SPDX-FileCopyrightText: 2017-2020 Harald Sitter // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL package models // ProjectFilter is used to filter projects on find() calls. // The type(names) in this struct must be chosen so that an uninitialized // filter results in match-all behavior! type ProjectFilter struct { - ID string `form:"id"` // basename of project path - RepoPath string `form:"repo"` // complete repo path - ActiveOnly bool `form:"active"` // whether to select active projects - InactiveOnly bool `form:"inactive"` // whether to select inactive projects - I18nComponent string `form:"i18n_component"` // the i18n component (aka identifier) + ID string `form:"id"` // DEPRECATED! replaced by basename, don't use + Basename string `form:"basename"` // basename of the project + RepoPath string `form:"repo"` // complete repo path + ActiveOnly bool `form:"active"` // whether to select active projects + InactiveOnly bool `form:"inactive"` // whether to select inactive projects } diff --git a/services/project.go b/services/project.go index e8e0e2c..17d1c34 100644 --- a/services/project.go +++ b/services/project.go @@ -1,80 +1,92 @@ // SPDX-FileCopyrightText: 2017-2020 Harald Sitter // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL package services import ( "path/filepath" "strings" "invent.kde.org/sysadmin/projects-api.git/models" ) type ProjectService struct { dao gitDAO } func NewProjectService(dao gitDAO) *ProjectService { return &ProjectService{dao} } func (s *ProjectService) Get(path string) (*models.Project, error) { return s.dao.Get(path) } +func (s *ProjectService) GetByIdentifier(id string) (*models.Project, error) { + for _, path := range s.dao.List() { + if !s.isProject(path) { + continue + } + + model, _ := s.Get(path) + if id == model.Identifier { + return model, nil + } + } + + return nil, nil +} + func (s *ProjectService) isProject(path string) bool { model, err := s.Get(path) if err != nil { panic(err) } return model != nil && model.Repo != "" } func (s *ProjectService) Find(filter *models.ProjectFilter) ([]string, error) { matches := []string{} for _, path := range s.dao.List() { if !s.isProject(path) { continue } - if len(filter.ID) != 0 && filter.ID != filepath.Base(path) { + if len(filter.Basename) != 0 && filter.Basename != filepath.Base(path) { continue } model, _ := s.Get(path) if len(filter.RepoPath) != 0 { if model.Repo != filter.RepoPath { continue // doesn't match repopath constraint } } if filter.ActiveOnly && !model.Active { continue } if filter.InactiveOnly && model.Active { continue } - if len(filter.I18nComponent) != 0 && filter.I18nComponent != model.Identifier { - continue - } matches = append(matches, path) } return matches, nil } func (s *ProjectService) List(prefix string) ([]string, error) { matches := []string{} for _, path := range s.dao.List() { if !s.isProject(path) { continue } if len(prefix) == 0 || path == prefix || strings.HasPrefix(path, prefix+"/") { matches = append(matches, path) } } return matches, nil } diff --git a/services/project_test.go b/services/project_test.go index f6f7eca..364a975 100644 --- a/services/project_test.go +++ b/services/project_test.go @@ -1,107 +1,110 @@ -// SPDX-FileCopyrightText: 2017-2019 Harald Sitter +// SPDX-FileCopyrightText: 2017-2020 Harald Sitter // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL package services import ( "testing" "time" "invent.kde.org/sysadmin/projects-api.git/models" "github.com/stretchr/testify/assert" ) type GitDAO struct { } func NewGitDAO() *GitDAO { return &GitDAO{} } func (d *GitDAO) Age() time.Duration { return 0 } func (d *GitDAO) Get(path string) (*models.Project, error) { if path == "calligra/krita" { return &models.Project{ - Repo: "kritarepo", + Repo: "kritarepo", + Identifier: "kritaid", }, nil } if path == "calligra/krita-extensions/krita-analogies" { return &models.Project{ - Repo: "krita-analogiesrepo", - Active: true, + Repo: "krita-analogiesrepo", + Identifier: "krita-analogiesid", + Active: true, }, nil } if path == "frameworks/solid" { return &models.Project{ - Repo: "solidrepo", + Repo: "solidrepo", + Identifier: "solidid", }, nil } if path == "calligra" { return nil, nil } panic("unknown path requested " + path) } func (d *GitDAO) List() []string { return []string{"calligra", "calligra/krita", "calligra/krita-extensions/krita-analogies", "frameworks/solid"} } func (d *GitDAO) UpdateClone() string { return "" } func TestProjectFind(t *testing.T) { s := NewProjectService(NewGitDAO()) t.Run("no filter", func(t *testing.T) { ret, err := s.Find(&models.ProjectFilter{}) assert.NoError(t, err) assert.Equal(t, []string{"calligra/krita", "calligra/krita-extensions/krita-analogies", "frameworks/solid"}, ret) }) t.Run("filter by id", func(t *testing.T) { - ret, err := s.Find(&models.ProjectFilter{ID: "krita"}) + ret, err := s.Find(&models.ProjectFilter{Basename: "krita"}) assert.NoError(t, err) assert.Equal(t, []string{"calligra/krita"}, ret) }) t.Run("filter by repo", func(t *testing.T) { ret, err := s.Find(&models.ProjectFilter{RepoPath: "kritarepo"}) assert.NoError(t, err) assert.Equal(t, []string{"calligra/krita"}, ret) }) t.Run("filter by active", func(t *testing.T) { ret, err := s.Find(&models.ProjectFilter{ActiveOnly: true}) assert.NoError(t, err) assert.Equal(t, []string{"calligra/krita-extensions/krita-analogies"}, ret) }) } func TestProjectList(t *testing.T) { s := NewProjectService(NewGitDAO()) t.Run("root", func(t *testing.T) { ret, err := s.List("") assert.NoError(t, err) assert.Equal(t, []string{"calligra/krita", "calligra/krita-extensions/krita-analogies", "frameworks/solid"}, ret) }) t.Run("component", func(t *testing.T) { ret, err := s.List("calligra") assert.NoError(t, err) assert.Equal(t, []string{"calligra/krita", "calligra/krita-extensions/krita-analogies"}, ret) }) t.Run("project", func(t *testing.T) { ret, err := s.List("calligra/krita") assert.NoError(t, err) // This must not include krita-extensions as it is not a child, it happens // to have the same stringy prefix though. assert.Equal(t, []string{"calligra/krita"}, ret) }) }