diff --git a/apis/project.go b/apis/project.go index 469fa41..6ee033e 100644 --- a/apis/project.go +++ b/apis/project.go @@ -1,296 +1,309 @@ // 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) - Identifiers() ([]string, error) + Identifiers(filter *models.ProjectFilter) ([]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) rg.GET("/identifiers", r.identifiers) } /** * @apiDefine ProjectSuccessExample * @apiSuccessExample {json} Success-Response: * { * "i18n": { * "stable": "none", * "stableKF5": "krita/3.1", * "trunk": "none", * "trunkKF5": "master", * "component": "krita" * }, * "repo": "krita", * "path": "calligra/krita", * "identifier": "krita" * } */ /** * @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 identifier * * @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} 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 {Bool} any_i18n Whether to find only projects that have at least + * one i18n branch set. + * This defaults to false, giving you all repos. * @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! 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 Paths * @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) } /** - * @api {get} /identifiers List Identifiers - * - * @apiVersion 1.0.0 - * @apiGroup Project - * @apiName identifiers - * - * @apiDescription Lists all *identifiers*. Since the migration to gitlab - * identifiers are equal to the i18n component. They act as unique - * identifier string for a given project. - * - * @apiSuccessExample {json} Project (/identifiers): - * [ - * "krita", - * "solid", - * "maui-dialer" - * "..." - * ] - */ +* @api {get} /identifiers List Identifiers +* +* @apiVersion 1.0.0 +* @apiGroup Project +* @apiName identifiers +* +* @apiDescription Lists all *identifiers*. Since the migration to gitlab +* identifiers are equal to the i18n component. They act as unique +* identifier string for a given project. +* This endpoint may be used with the same filters as the /find endpoint +* to select only identifiers matching the filter constraints. +* +* @apiSuccessExample {json} Identifiers (/identifiers): +* [ +* "krita", +* "solid", +* "maui-dialer" +* "..." +* ] + * @apiSuccessExample {json} Filter with i18n (/identifiers?any_i18n=true): +* [ +* "solid", +* "..." +* ] +*/ func (r *projectResource) identifiers(c *gin.Context) { - matches, err := r.service.Identifiers() + var filter models.ProjectFilter + c.BindQuery(&filter) + + matches, err := r.service.Identifiers(&filter) 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 0584141..2f17eea 100644 --- a/apis/project_test.go +++ b/apis/project_test.go @@ -1,105 +1,105 @@ // 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.PathOnDisk = "calligra/krita" project.Repo = "krita" project.Identifier = "kritaid" 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.PathOnDisk = "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.Basename == "krita" && filter.RepoPath == "" { return projects, nil } if filter.Basename == "" && filter.RepoPath == "krita" { return projects, nil } if filter.Basename == "" && filter.InactiveOnly { return []string{"kde/hole"}, nil } if filter.Basename == "" && filter.RepoPath == "" { return append(projects, "frameworks/solid"), nil } 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 (s *ProjectService) Identifiers() ([]string, error) { +func (s *ProjectService) Identifiers(filter *models.ProjectFilter) ([]string, error) { return []string{"kritaid"}, nil } func init() { v1 := router.Group("/v1") { ServeProjectResource(v1, NewProjectService()) } } func TestProject(t *testing.T) { kritaObj := `{"i18n":{"trunkKF5":"", "component":"", "stable":"", "stableKF5":"", "trunk":""}, "identifier":"kritaid", "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"]`}, {"list identifiers", "GET", "/v1/identifiers", "", http.StatusOK, `["kritaid"]`}, }) } diff --git a/models/project.go b/models/project.go index 2d07d0d..840321d 100644 --- a/models/project.go +++ b/models/project.go @@ -1,20 +1,20 @@ // SPDX-FileCopyrightText: 2017-2020 Harald Sitter // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL package models // Project is the core model of a project entity. type Project struct { I18n I18nData `yaml:"i18n" json:"i18n"` // With the migration to invent projectpath became useless. It's the legacy // path for awkward reasons. Instead publish the real on disk path and // expose the legacy path under a different name. PathOnDisk string `yaml:"-" json:"path"` // TODO v2 throw away hopefully (depends on metadata getting better replacement PathInMetadata string `yaml:"projectpath" json:"__do_not_use-legacy-projectpath,omitempty"` - Repo string `yaml:"repopath" json:"repo"` - Active bool `yaml:"repoactive" json:"__do_not_use-legacy-active"` // do not marshal to json, we presently have no use case for it - Identifier string `yaml:"identifier" json:"identifier"` // also marshal'd via I18nData + Repo string `yaml:"repopath" json:"repo"` + Active bool `yaml:"repoactive" json:"-"` // do not marshal to json, we presently have no use case for it + Identifier string `yaml:"identifier" json:"identifier"` // also marshal'd via I18nData } diff --git a/models/project_filter.go b/models/project_filter.go index 1e0a4c5..8293868 100644 --- a/models/project_filter.go +++ b/models/project_filter.go @@ -1,15 +1,16 @@ // 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"` // 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 + AnyI18n bool `form:"any_i18n"` // select only ones with any i18n branch } diff --git a/services/project.go b/services/project.go index 7f2bcf6..a5b53ea 100644 --- a/services/project.go +++ b/services/project.go @@ -1,107 +1,127 @@ // 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{} +func (s *ProjectService) filterProjects(filter *models.ProjectFilter) ([]*models.Project, error) { + matches := []*models.Project{} for _, path := range s.dao.List() { if !s.isProject(path) { continue } 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 filter.AnyI18n { + i18n := model.I18n + if i18n.Stable == "none" && i18n.Trunk == "none" && + i18n.StableKF5 == "none" && i18n.TrunkKF5 == "none" { + continue + } + } + + matches = append(matches, model) + } + + return matches, nil +} + +func (s *ProjectService) Find(filter *models.ProjectFilter) ([]string, error) { + matches := []string{} + projects, err := s.filterProjects(filter) + if err != nil { + return matches, err } + for _, project := range projects { matches = append(matches, project.PathOnDisk) + } 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 } -func (s *ProjectService) Identifiers() ([]string, error) { +func (s *ProjectService) Identifiers(filter *models.ProjectFilter) ([]string, error) { matches := []string{} - for _, path := range s.dao.List() { - if !s.isProject(path) { - continue - } - - model, _ := s.Get(path) - matches = append(matches, model.Identifier) + projects, err := s.filterProjects(filter) + if err != nil { + return matches, err } + for _, project := range projects { + matches = append(matches, project.Identifier) + } return matches, nil } diff --git a/services/project_test.go b/services/project_test.go index 93ef70e..cc73fd0 100644 --- a/services/project_test.go +++ b/services/project_test.go @@ -1,129 +1,137 @@ // 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", Identifier: "kritaid", PathOnDisk: path, }, nil } if path == "calligra/krita-extensions/krita-analogies" { return &models.Project{ Repo: "krita-analogiesrepo", Identifier: "krita-analogiesid", Active: true, PathOnDisk: path, }, nil } if path == "frameworks/solid" { return &models.Project{ Repo: "solidrepo", Identifier: "solidid", PathOnDisk: path, }, 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{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) }) } func TestProjectIdentifiers(t *testing.T) { s := NewProjectService(NewGitDAO()) t.Run("get-by-id", func(t *testing.T) { ret, err := s.GetByIdentifier("kritaid") assert.NoError(t, err) assert.Equal(t, "kritaid", ret.Identifier) assert.Equal(t, "kritarepo", ret.Repo) }) t.Run("list", func(t *testing.T) { - ret, err := s.Identifiers() + filter := &models.ProjectFilter{} + ret, err := s.Identifiers(filter) assert.NoError(t, err) assert.Equal(t, []string{"kritaid", "krita-analogiesid", "solidid"}, ret) }) + + t.Run("filter-only-active", func(t *testing.T) { + filter := &models.ProjectFilter{ActiveOnly: true} + ret, err := s.Identifiers(filter) + assert.NoError(t, err) + assert.Equal(t, []string{"krita-analogiesid"}, ret) + }) }