From 1f951cdebae314a45b5e4ec1d464f1b4a53c8002 Mon Sep 17 00:00:00 2001 From: David Svantesson Date: Tue, 3 Sep 2019 17:46:24 +0200 Subject: [PATCH] Add API endpoint for accessing repo topics (#7963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Create API endpoints for repo topics. Signed-off-by: David Svantesson * Generate swagger Signed-off-by: David Svantesson * Add documentation to functions Signed-off-by: David Svantesson * Grammar fix Signed-off-by: David Svantesson * Fix function comment Signed-off-by: David Svantesson * Can't use FindTopics when looking for a single repo topic, as it doesnt use exact match Signed-off-by: David Svantesson * Add PUT ​/repos​/{owner}​/{repo}​/topics and remove GET ​/repos​/{owner}​/{repo}​/topics * Ignore if topic is sent twice in same request, refactoring. Signed-off-by: David Svantesson * Fix topic dropdown with api changes. Signed-off-by: David Svantesson * Style fix Signed-off-by: David Svantesson * Update API documentation Signed-off-by: David Svantesson * Better way to handle duplicate topics in slice Signed-off-by: David Svantesson * Make response element TopicName an array of strings, instead of using an array of TopicName Signed-off-by: David Svantesson * Add test cases for API Repo Topics. Signed-off-by: David Svantesson * Fix format of tests Signed-off-by: David Svantesson * Fix comments Signed-off-by: David Svantesson * Fix unit tests after adding some more topics to the test fixture. Signed-off-by: David Svantesson * Update models/topic.go Limit multiple if else if ... Co-Authored-By: Antoine GIRARD * Engine as first parameter in function Co-Authored-By: Antoine GIRARD * Replace magic numbers with http status code constants. Signed-off-by: David Svantesson * Fix variable scope Signed-off-by: David Svantesson * Test one read with login and one with token Signed-off-by: David Svantesson * Add some more tests Signed-off-by: David Svantesson * Apply suggestions from code review Use empty struct for efficiency Co-Authored-By: Lauris BH * Add test case to check access for user with write access Signed-off-by: David Svantesson * Fix access, repo admin required to change topics Signed-off-by: David Svantesson * Correct first test to be without token Signed-off-by: David Svantesson * Any repo reader should be able to access topics. * No need for string pointer Signed-off-by: David Svantesson --- integrations/api_repo_topic_test.go | 124 +++++++++++++ models/fixtures/repo_topic.yml | 8 + models/fixtures/topic.yml | 8 + models/topic.go | 154 +++++++++++++--- models/topic_test.go | 19 +- modules/structs/repo_topic.go | 29 +++ public/js/index.js | 6 +- routers/api/v1/api.go | 8 + routers/api/v1/convert/convert.go | 11 ++ routers/api/v1/repo/repo.go | 42 ----- routers/api/v1/repo/topic.go | 274 ++++++++++++++++++++++++++++ routers/api/v1/swagger/options.go | 3 + routers/api/v1/swagger/repo.go | 14 ++ routers/repo/topic.go | 21 +-- templates/swagger/v1_json.tmpl | 228 ++++++++++++++++++++++- 15 files changed, 849 insertions(+), 100 deletions(-) create mode 100644 integrations/api_repo_topic_test.go create mode 100644 modules/structs/repo_topic.go create mode 100644 routers/api/v1/repo/topic.go diff --git a/integrations/api_repo_topic_test.go b/integrations/api_repo_topic_test.go new file mode 100644 index 0000000000..34c33d1b25 --- /dev/null +++ b/integrations/api_repo_topic_test.go @@ -0,0 +1,124 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestAPIRepoTopic(t *testing.T) { + prepareTestEnv(t) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) // owner of repo2 + user3 := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User) // owner of repo3 + user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) // write access to repo 3 + repo2 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 2}).(*models.Repository) + repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository) + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session) + + // Test read topics using login + url := fmt.Sprintf("/api/v1/repos/%s/%s/topics", user2.Name, repo2.Name) + req := NewRequest(t, "GET", url) + res := session.MakeRequest(t, req, http.StatusOK) + var topics *api.TopicName + DecodeJSON(t, res, &topics) + assert.ElementsMatch(t, []string{"topicname1", "topicname2"}, topics.TopicNames) + + // Log out user2 + session = emptyTestSession(t) + url = fmt.Sprintf("/api/v1/repos/%s/%s/topics?token=%s", user2.Name, repo2.Name, token2) + + // Test delete a topic + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "Topicname1", token2) + res = session.MakeRequest(t, req, http.StatusNoContent) + + // Test add an existing topic + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "Golang", token2) + res = session.MakeRequest(t, req, http.StatusNoContent) + + // Test add a topic + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "topicName3", token2) + res = session.MakeRequest(t, req, http.StatusNoContent) + + // Test read topics using token + req = NewRequest(t, "GET", url) + res = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.ElementsMatch(t, []string{"topicname2", "golang", "topicname3"}, topics.TopicNames) + + // Test replace topics + newTopics := []string{" windows ", " ", "MAC "} + req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{ + Topics: newTopics, + }) + res = session.MakeRequest(t, req, http.StatusNoContent) + req = NewRequest(t, "GET", url) + res = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames) + + // Test replace topics with something invalid + newTopics = []string{"topicname1", "topicname2", "topicname!"} + req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{ + Topics: newTopics, + }) + res = session.MakeRequest(t, req, http.StatusUnprocessableEntity) + req = NewRequest(t, "GET", url) + res = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames) + + // Test with some topics multiple times, less than 25 unique + newTopics = []string{"t1", "t2", "t1", "t3", "t4", "t5", "t6", "t7", "t8", "t9", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25"} + req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{ + Topics: newTopics, + }) + res = session.MakeRequest(t, req, http.StatusNoContent) + req = NewRequest(t, "GET", url) + res = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Equal(t, 25, len(topics.TopicNames)) + + // Test writing more topics than allowed + newTopics = append(newTopics, "t26") + req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{ + Topics: newTopics, + }) + res = session.MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test add a topic when there is already maximum + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "t26", token2) + res = session.MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test delete a topic that repo doesn't have + req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "Topicname1", token2) + res = session.MakeRequest(t, req, http.StatusNotFound) + + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) + + // Test read topics with write access + url = fmt.Sprintf("/api/v1/repos/%s/%s/topics?token=%s", user3.Name, repo3.Name, token4) + req = NewRequest(t, "GET", url) + res = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Equal(t, 0, len(topics.TopicNames)) + + // Test add a topic to repo with write access (requires repo admin access) + req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user3.Name, repo3.Name, "topicName", token4) + res = session.MakeRequest(t, req, http.StatusForbidden) + +} diff --git a/models/fixtures/repo_topic.yml b/models/fixtures/repo_topic.yml index 7041ccfd09..f166faccc1 100644 --- a/models/fixtures/repo_topic.yml +++ b/models/fixtures/repo_topic.yml @@ -17,3 +17,11 @@ - repo_id: 33 topic_id: 4 + +- + repo_id: 2 + topic_id: 5 + +- + repo_id: 2 + topic_id: 6 diff --git a/models/fixtures/topic.yml b/models/fixtures/topic.yml index c868b207cb..6cd0b37fa1 100644 --- a/models/fixtures/topic.yml +++ b/models/fixtures/topic.yml @@ -15,3 +15,11 @@ - id: 4 name: graphql repo_count: 1 + +- id: 5 + name: topicname1 + repo_count: 1 + +- id: 6 + name: topicname2 + repo_count: 2 diff --git a/models/topic.go b/models/topic.go index 8a587acc3a..e4fda03fc4 100644 --- a/models/topic.go +++ b/models/topic.go @@ -54,11 +54,38 @@ func (err ErrTopicNotExist) Error() string { return fmt.Sprintf("topic is not exist [name: %s]", err.Name) } -// ValidateTopic checks topics by length and match pattern rules +// ValidateTopic checks a topic by length and match pattern rules func ValidateTopic(topic string) bool { return len(topic) <= 35 && topicPattern.MatchString(topic) } +// SanitizeAndValidateTopics sanitizes and checks an array or topics +func SanitizeAndValidateTopics(topics []string) (validTopics []string, invalidTopics []string) { + validTopics = make([]string, 0) + mValidTopics := make(map[string]struct{}) + invalidTopics = make([]string, 0) + + for _, topic := range topics { + topic = strings.TrimSpace(strings.ToLower(topic)) + // ignore empty string + if len(topic) == 0 { + continue + } + // ignore same topic twice + if _, ok := mValidTopics[topic]; ok { + continue + } + if ValidateTopic(topic) { + validTopics = append(validTopics, topic) + mValidTopics[topic] = struct{}{} + } else { + invalidTopics = append(invalidTopics, topic) + } + } + + return validTopics, invalidTopics +} + // GetTopicByName retrieves topic by name func GetTopicByName(name string) (*Topic, error) { var topic Topic @@ -70,6 +97,54 @@ func GetTopicByName(name string) (*Topic, error) { return &topic, nil } +// addTopicByNameToRepo adds a topic name to a repo and increments the topic count. +// Returns topic after the addition +func addTopicByNameToRepo(e Engine, repoID int64, topicName string) (*Topic, error) { + var topic Topic + has, err := e.Where("name = ?", topicName).Get(&topic) + if err != nil { + return nil, err + } + if !has { + topic.Name = topicName + topic.RepoCount = 1 + if _, err := e.Insert(&topic); err != nil { + return nil, err + } + } else { + topic.RepoCount++ + if _, err := e.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil { + return nil, err + } + } + + if _, err := e.Insert(&RepoTopic{ + RepoID: repoID, + TopicID: topic.ID, + }); err != nil { + return nil, err + } + + return &topic, nil +} + +// removeTopicFromRepo remove a topic from a repo and decrements the topic repo count +func removeTopicFromRepo(repoID int64, topic *Topic, e Engine) error { + topic.RepoCount-- + if _, err := e.ID(topic.ID).Cols("repo_count").Update(topic); err != nil { + return err + } + + if _, err := e.Delete(&RepoTopic{ + RepoID: repoID, + TopicID: topic.ID, + }); err != nil { + return err + } + + return nil +} + // FindTopicOptions represents the options when fdin topics type FindTopicOptions struct { RepoID int64 @@ -103,6 +178,50 @@ func FindTopics(opts *FindTopicOptions) (topics []*Topic, err error) { return topics, sess.Desc("topic.repo_count").Find(&topics) } +// GetRepoTopicByName retrives topic from name for a repo if it exist +func GetRepoTopicByName(repoID int64, topicName string) (*Topic, error) { + var cond = builder.NewCond() + var topic Topic + cond = cond.And(builder.Eq{"repo_topic.repo_id": repoID}).And(builder.Eq{"topic.name": topicName}) + sess := x.Table("topic").Where(cond) + sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id") + has, err := sess.Get(&topic) + if has { + return &topic, err + } + return nil, err +} + +// AddTopic adds a topic name to a repository (if it does not already have it) +func AddTopic(repoID int64, topicName string) (*Topic, error) { + topic, err := GetRepoTopicByName(repoID, topicName) + if err != nil { + return nil, err + } + if topic != nil { + // Repo already have topic + return topic, nil + } + + return addTopicByNameToRepo(x, repoID, topicName) +} + +// DeleteTopic removes a topic name from a repository (if it has it) +func DeleteTopic(repoID int64, topicName string) (*Topic, error) { + topic, err := GetRepoTopicByName(repoID, topicName) + if err != nil { + return nil, err + } + if topic == nil { + // Repo doesn't have topic, can't be removed + return nil, nil + } + + err = removeTopicFromRepo(repoID, topic, x) + + return topic, err +} + // SaveTopics save topics to a repository func SaveTopics(repoID int64, topicNames ...string) error { topics, err := FindTopics(&FindTopicOptions{ @@ -152,40 +271,15 @@ func SaveTopics(repoID int64, topicNames ...string) error { } for _, topicName := range addedTopicNames { - var topic Topic - if has, err := sess.Where("name = ?", topicName).Get(&topic); err != nil { - return err - } else if !has { - topic.Name = topicName - topic.RepoCount = 1 - if _, err := sess.Insert(&topic); err != nil { - return err - } - } else { - topic.RepoCount++ - if _, err := sess.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil { - return err - } - } - - if _, err := sess.Insert(&RepoTopic{ - RepoID: repoID, - TopicID: topic.ID, - }); err != nil { + _, err := addTopicByNameToRepo(sess, repoID, topicName) + if err != nil { return err } } for _, topic := range removeTopics { - topic.RepoCount-- - if _, err := sess.ID(topic.ID).Cols("repo_count").Update(topic); err != nil { - return err - } - - if _, err := sess.Delete(&RepoTopic{ - RepoID: repoID, - TopicID: topic.ID, - }); err != nil { + err := removeTopicFromRepo(repoID, topic, sess) + if err != nil { return err } } diff --git a/models/topic_test.go b/models/topic_test.go index 65e52afb12..c173c7bf2a 100644 --- a/models/topic_test.go +++ b/models/topic_test.go @@ -11,11 +11,15 @@ import ( ) func TestAddTopic(t *testing.T) { + totalNrOfTopics := 6 + repo1NrOfTopics := 3 + repo2NrOfTopics := 2 + assert.NoError(t, PrepareTestDatabase()) topics, err := FindTopics(&FindTopicOptions{}) assert.NoError(t, err) - assert.EqualValues(t, 4, len(topics)) + assert.EqualValues(t, totalNrOfTopics, len(topics)) topics, err = FindTopics(&FindTopicOptions{ Limit: 2, @@ -27,33 +31,36 @@ func TestAddTopic(t *testing.T) { RepoID: 1, }) assert.NoError(t, err) - assert.EqualValues(t, 3, len(topics)) + assert.EqualValues(t, repo1NrOfTopics, len(topics)) assert.NoError(t, SaveTopics(2, "golang")) + repo2NrOfTopics = 1 topics, err = FindTopics(&FindTopicOptions{}) assert.NoError(t, err) - assert.EqualValues(t, 4, len(topics)) + assert.EqualValues(t, totalNrOfTopics, len(topics)) topics, err = FindTopics(&FindTopicOptions{ RepoID: 2, }) assert.NoError(t, err) - assert.EqualValues(t, 1, len(topics)) + assert.EqualValues(t, repo2NrOfTopics, len(topics)) assert.NoError(t, SaveTopics(2, "golang", "gitea")) + repo2NrOfTopics = 2 + totalNrOfTopics++ topic, err := GetTopicByName("gitea") assert.NoError(t, err) assert.EqualValues(t, 1, topic.RepoCount) topics, err = FindTopics(&FindTopicOptions{}) assert.NoError(t, err) - assert.EqualValues(t, 5, len(topics)) + assert.EqualValues(t, totalNrOfTopics, len(topics)) topics, err = FindTopics(&FindTopicOptions{ RepoID: 2, }) assert.NoError(t, err) - assert.EqualValues(t, 2, len(topics)) + assert.EqualValues(t, repo2NrOfTopics, len(topics)) } func TestTopicValidator(t *testing.T) { diff --git a/modules/structs/repo_topic.go b/modules/structs/repo_topic.go new file mode 100644 index 0000000000..294d56a953 --- /dev/null +++ b/modules/structs/repo_topic.go @@ -0,0 +1,29 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package structs + +import ( + "time" +) + +// TopicResponse for returning topics +type TopicResponse struct { + ID int64 `json:"id"` + Name string `json:"topic_name"` + RepoCount int `json:"repo_count"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// TopicName a list of repo topic names +type TopicName struct { + TopicNames []string `json:"topics"` +} + +// RepoTopicOptions a collection of repo topic names +type RepoTopicOptions struct { + // list of topic names + Topics []string `json:"topics"` +} diff --git a/public/js/index.js b/public/js/index.js index 15f8d02bbd..882f19e13d 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -2936,14 +2936,14 @@ function initTopicbar() { let found = false; for (let i=0;i < res.topics.length;i++) { // skip currently added tags - if (current_topics.indexOf(res.topics[i].Name) != -1){ + if (current_topics.indexOf(res.topics[i].topic_name) != -1){ continue; } - if (res.topics[i].Name.toLowerCase() === query.toLowerCase()){ + if (res.topics[i].topic_name.toLowerCase() === query.toLowerCase()){ found_query = true; } - formattedResponse.results.push({"description": res.topics[i].Name, "data-value": res.topics[i].Name}); + formattedResponse.results.push({"description": res.topics[i].topic_name, "data-value": res.topics[i].topic_name}); found = true; } formattedResponse.success = found; diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2842d78cd3..c57edf6a99 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -771,6 +771,14 @@ func RegisterRoutes(m *macaron.Macaron) { m.Delete("", bind(api.DeleteFileOptions{}), repo.DeleteFile) }, reqRepoWriter(models.UnitTypeCode), reqToken()) }, reqRepoReader(models.UnitTypeCode)) + m.Group("/topics", func() { + m.Combo("").Get(repo.ListTopics). + Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics) + m.Group("/:topic", func() { + m.Combo("").Put(reqToken(), repo.AddTopic). + Delete(reqToken(), repo.DeleteTopic) + }, reqAdmin()) + }, reqAnyRepoReader()) }, repoAssignment()) }) diff --git a/routers/api/v1/convert/convert.go b/routers/api/v1/convert/convert.go index 90202117cc..40e4ca7ae3 100644 --- a/routers/api/v1/convert/convert.go +++ b/routers/api/v1/convert/convert.go @@ -291,3 +291,14 @@ func ToCommitMeta(repo *models.Repository, tag *git.Tag) *api.CommitMeta { URL: util.URLJoin(repo.APIURL(), "git/commits", tag.ID.String()), } } + +// ToTopicResponse convert from models.Topic to api.TopicResponse +func ToTopicResponse(topic *models.Topic) *api.TopicResponse { + return &api.TopicResponse{ + ID: topic.ID, + Name: topic.Name, + RepoCount: topic.RepoCount, + Created: topic.CreatedUnix.AsTime(), + Updated: topic.UpdatedUnix.AsTime(), + } +} diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index eccff8c387..82bfa58b7a 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -871,45 +871,3 @@ func MirrorSync(ctx *context.APIContext) { go models.MirrorQueue.Add(repo.ID) ctx.Status(200) } - -// TopicSearch search for creating topic -func TopicSearch(ctx *context.Context) { - // swagger:operation GET /topics/search repository topicSearch - // --- - // summary: search topics via keyword - // produces: - // - application/json - // parameters: - // - name: q - // in: query - // description: keywords to search - // required: true - // type: string - // responses: - // "200": - // "$ref": "#/responses/Repository" - if ctx.User == nil { - ctx.JSON(403, map[string]interface{}{ - "message": "Only owners could change the topics.", - }) - return - } - - kw := ctx.Query("q") - - topics, err := models.FindTopics(&models.FindTopicOptions{ - Keyword: kw, - Limit: 10, - }) - if err != nil { - log.Error("SearchTopics failed: %v", err) - ctx.JSON(500, map[string]interface{}{ - "message": "Search topics failed.", - }) - return - } - - ctx.JSON(200, map[string]interface{}{ - "topics": topics, - }) -} diff --git a/routers/api/v1/repo/topic.go b/routers/api/v1/repo/topic.go new file mode 100644 index 0000000000..6c3ac0020a --- /dev/null +++ b/routers/api/v1/repo/topic.go @@ -0,0 +1,274 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/convert" +) + +// ListTopics returns list of current topics for repo +func ListTopics(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/topics repository repoListTopics + // --- + // summary: Get list of topics that a repository has + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/TopicNames" + + topics, err := models.FindTopics(&models.FindTopicOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + log.Error("ListTopics failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "message": "ListTopics failed.", + }) + return + } + + topicNames := make([]string, len(topics)) + for i, topic := range topics { + topicNames[i] = topic.Name + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "topics": topicNames, + }) +} + +// UpdateTopics updates repo with a new set of topics +func UpdateTopics(ctx *context.APIContext, form api.RepoTopicOptions) { + // swagger:operation PUT /repos/{owner}/{repo}/topics repository repoUpdateTopics + // --- + // summary: Replace list of topics for a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/RepoTopicOptions" + // responses: + // "204": + // "$ref": "#/responses/empty" + + topicNames := form.Topics + validTopics, invalidTopics := models.SanitizeAndValidateTopics(topicNames) + + if len(validTopics) > 25 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]interface{}{ + "invalidTopics": nil, + "message": "Exceeding maximum number of topics per repo", + }) + return + } + + if len(invalidTopics) > 0 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]interface{}{ + "invalidTopics": invalidTopics, + "message": "Topic names are invalid", + }) + return + } + + err := models.SaveTopics(ctx.Repo.Repository.ID, validTopics...) + if err != nil { + log.Error("SaveTopics failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "message": "Save topics failed.", + }) + return + } + + ctx.Status(http.StatusNoContent) +} + +// AddTopic adds a topic name to a repo +func AddTopic(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/topics/{topic} repository repoAddTopíc + // --- + // summary: Add a topic to a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: topic + // in: path + // description: name of the topic to add + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + + topicName := strings.TrimSpace(strings.ToLower(ctx.Params(":topic"))) + + if !models.ValidateTopic(topicName) { + ctx.Error(http.StatusUnprocessableEntity, "", "Topic name is invalid") + return + } + + // Prevent adding more topics than allowed to repo + topics, err := models.FindTopics(&models.FindTopicOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + log.Error("AddTopic failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "message": "ListTopics failed.", + }) + return + } + if len(topics) >= 25 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]interface{}{ + "message": "Exceeding maximum allowed topics per repo.", + }) + return + } + + _, err = models.AddTopic(ctx.Repo.Repository.ID, topicName) + if err != nil { + log.Error("AddTopic failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "message": "AddTopic failed.", + }) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteTopic removes topic name from repo +func DeleteTopic(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/topics/{topic} repository repoDeleteTopic + // --- + // summary: Delete a topic from a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: topic + // in: path + // description: name of the topic to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + topicName := strings.TrimSpace(strings.ToLower(ctx.Params(":topic"))) + + if !models.ValidateTopic(topicName) { + ctx.Error(http.StatusUnprocessableEntity, "", "Topic name is invalid") + return + } + + topic, err := models.DeleteTopic(ctx.Repo.Repository.ID, topicName) + if err != nil { + log.Error("DeleteTopic failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "message": "DeleteTopic failed.", + }) + return + } + + if topic == nil { + ctx.NotFound() + } + + ctx.Status(http.StatusNoContent) +} + +// TopicSearch search for creating topic +func TopicSearch(ctx *context.Context) { + // swagger:operation GET /topics/search repository topicSearch + // --- + // summary: search topics via keyword + // produces: + // - application/json + // parameters: + // - name: q + // in: query + // description: keywords to search + // required: true + // type: string + // responses: + // "200": + // "$ref": "#/responses/TopicListResponse" + if ctx.User == nil { + ctx.JSON(http.StatusForbidden, map[string]interface{}{ + "message": "Only owners could change the topics.", + }) + return + } + + kw := ctx.Query("q") + + topics, err := models.FindTopics(&models.FindTopicOptions{ + Keyword: kw, + Limit: 10, + }) + if err != nil { + log.Error("SearchTopics failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "message": "Search topics failed.", + }) + return + } + + topicResponses := make([]*api.TopicResponse, len(topics)) + for i, topic := range topics { + topicResponses[i] = convert.ToTopicResponse(topic) + } + ctx.JSON(http.StatusOK, map[string]interface{}{ + "topics": topicResponses, + }) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index c1196eeb71..80e4bf422a 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -117,4 +117,7 @@ type swaggerParameterBodies struct { // in:body DeleteFileOptions api.DeleteFileOptions + + // in:body + RepoTopicOptions api.RepoTopicOptions } diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 422cc0861c..4ac5c6d2d5 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -246,3 +246,17 @@ type swaggerFileDeleteResponse struct { //in: body Body api.FileDeleteResponse `json:"body"` } + +// TopicListResponse +// swagger:response TopicListResponse +type swaggerTopicListResponse struct { + //in: body + Body []api.TopicResponse `json:"body"` +} + +// TopicNames +// swagger:response TopicNames +type swaggerTopicNames struct { + //in: body + Body api.TopicName `json:"body"` +} diff --git a/routers/repo/topic.go b/routers/repo/topic.go index 4a1194bc2d..b23023ceba 100644 --- a/routers/repo/topic.go +++ b/routers/repo/topic.go @@ -27,24 +27,11 @@ func TopicsPost(ctx *context.Context) { topics = strings.Split(topicsStr, ",") } - invalidTopics := make([]string, 0) - i := 0 - for _, topic := range topics { - topic = strings.TrimSpace(strings.ToLower(topic)) - // ignore empty string - if len(topic) > 0 { - topics[i] = topic - i++ - } - if !models.ValidateTopic(topic) { - invalidTopics = append(invalidTopics, topic) - } - } - topics = topics[:i] + validTopics, invalidTopics := models.SanitizeAndValidateTopics(topics) - if len(topics) > 25 { + if len(validTopics) > 25 { ctx.JSON(422, map[string]interface{}{ - "invalidTopics": topics[:0], + "invalidTopics": nil, "message": ctx.Tr("repo.topic.count_prompt"), }) return @@ -58,7 +45,7 @@ func TopicsPost(ctx *context.Context) { return } - err := models.SaveTopics(ctx.Repo.Repository.ID, topics...) + err := models.SaveTopics(ctx.Repo.Repository.ID, validTopics...) if err != nil { log.Error("SaveTopics failed: %v", err) ctx.JSON(500, map[string]interface{}{ diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index eed60c044c..8cf22251a6 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5451,6 +5451,155 @@ } } }, + "/repos/{owner}/{repo}/topics": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get list of topics that a repository has", + "operationId": "repoListTopics", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/TopicNames" + } + } + }, + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Replace list of topics for a repository", + "operationId": "repoUpdateTopics", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/RepoTopicOptions" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, + "/repos/{owner}/{repo}/topics/{topic}": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Add a topic to a repository", + "operationId": "repoAddTopíc", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the topic to add", + "name": "topic", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete a topic from a repository", + "operationId": "repoDeleteTopic", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the topic to delete", + "name": "topic", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/repositories/{id}": { "get": { "produces": [ @@ -5815,7 +5964,7 @@ ], "responses": { "200": { - "$ref": "#/responses/Repository" + "$ref": "#/responses/TopicListResponse" } } } @@ -9561,6 +9710,21 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "RepoTopicOptions": { + "description": "RepoTopicOptions a collection of repo topic names", + "type": "object", + "properties": { + "topics": { + "description": "list of topic names", + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Topics" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Repository": { "description": "Repository represents a repository", "type": "object", @@ -9874,6 +10038,51 @@ "format": "int64", "x-go-package": "code.gitea.io/gitea/modules/timeutil" }, + "TopicName": { + "description": "TopicName a list of repo topic names", + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "TopicNames" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "TopicResponse": { + "description": "TopicResponse for returning topics", + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "repo_count": { + "type": "integer", + "format": "int64", + "x-go-name": "RepoCount" + }, + "topic_name": { + "type": "string", + "x-go-name": "Name" + }, + "updated": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "TrackedTime": { "description": "TrackedTime worked time for an issue / pr", "type": "object", @@ -10493,6 +10702,21 @@ } } }, + "TopicListResponse": { + "description": "TopicListResponse", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/TopicResponse" + } + } + }, + "TopicNames": { + "description": "TopicNames", + "schema": { + "$ref": "#/definitions/TopicName" + } + }, "TrackedTime": { "description": "TrackedTime", "schema": { @@ -10569,7 +10793,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/DeleteFileOptions" + "$ref": "#/definitions/RepoTopicOptions" } }, "redirect": {