From 1f951cdebae314a45b5e4ec1d464f1b4a53c8002 Mon Sep 17 00:00:00 2001 From: David Svantesson Date: Tue, 3 Sep 2019 17:46:24 +0200 Subject: 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 --- 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 ++ 6 files changed, 310 insertions(+), 42 deletions(-) create mode 100644 routers/api/v1/repo/topic.go (limited to 'routers/api') 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"` +} -- cgit v1.2.3