Skip to content

Commit 1f951cd

Browse files
davidsvantessonlunny
authored andcommitted
Add API endpoint for accessing repo topics (#7963)
* Create API endpoints for repo topics. Signed-off-by: David Svantesson <[email protected]> * Generate swagger Signed-off-by: David Svantesson <[email protected]> * Add documentation to functions Signed-off-by: David Svantesson <[email protected]> * Grammar fix Signed-off-by: David Svantesson <[email protected]> * Fix function comment Signed-off-by: David Svantesson <[email protected]> * Can't use FindTopics when looking for a single repo topic, as it doesnt use exact match Signed-off-by: David Svantesson <[email protected]> * 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 <[email protected]> * Fix topic dropdown with api changes. Signed-off-by: David Svantesson <[email protected]> * Style fix Signed-off-by: David Svantesson <[email protected]> * Update API documentation Signed-off-by: David Svantesson <[email protected]> * Better way to handle duplicate topics in slice Signed-off-by: David Svantesson <[email protected]> * Make response element TopicName an array of strings, instead of using an array of TopicName Signed-off-by: David Svantesson <[email protected]> * Add test cases for API Repo Topics. Signed-off-by: David Svantesson <[email protected]> * Fix format of tests Signed-off-by: David Svantesson <[email protected]> * Fix comments Signed-off-by: David Svantesson <[email protected]> * Fix unit tests after adding some more topics to the test fixture. Signed-off-by: David Svantesson <[email protected]> * Update models/topic.go Limit multiple if else if ... Co-Authored-By: Antoine GIRARD <[email protected]> * Engine as first parameter in function Co-Authored-By: Antoine GIRARD <[email protected]> * Replace magic numbers with http status code constants. Signed-off-by: David Svantesson <[email protected]> * Fix variable scope Signed-off-by: David Svantesson <[email protected]> * Test one read with login and one with token Signed-off-by: David Svantesson <[email protected]> * Add some more tests Signed-off-by: David Svantesson <[email protected]> * Apply suggestions from code review Use empty struct for efficiency Co-Authored-By: Lauris BH <[email protected]> * Add test case to check access for user with write access Signed-off-by: David Svantesson <[email protected]> * Fix access, repo admin required to change topics Signed-off-by: David Svantesson <[email protected]> * Correct first test to be without token Signed-off-by: David Svantesson <[email protected]> * Any repo reader should be able to access topics. * No need for string pointer Signed-off-by: David Svantesson <[email protected]>
1 parent 99d6863 commit 1f951cd

File tree

15 files changed

+849
-100
lines changed

15 files changed

+849
-100
lines changed

integrations/api_repo_topic_test.go

+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package integrations
6+
7+
import (
8+
"fmt"
9+
"net/http"
10+
"testing"
11+
12+
"code.gitea.io/gitea/models"
13+
api "code.gitea.io/gitea/modules/structs"
14+
15+
"github.com/stretchr/testify/assert"
16+
)
17+
18+
func TestAPIRepoTopic(t *testing.T) {
19+
prepareTestEnv(t)
20+
user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) // owner of repo2
21+
user3 := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User) // owner of repo3
22+
user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) // write access to repo 3
23+
repo2 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 2}).(*models.Repository)
24+
repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository)
25+
26+
// Get user2's token
27+
session := loginUser(t, user2.Name)
28+
token2 := getTokenForLoggedInUser(t, session)
29+
30+
// Test read topics using login
31+
url := fmt.Sprintf("/api/v1/repos/%s/%s/topics", user2.Name, repo2.Name)
32+
req := NewRequest(t, "GET", url)
33+
res := session.MakeRequest(t, req, http.StatusOK)
34+
var topics *api.TopicName
35+
DecodeJSON(t, res, &topics)
36+
assert.ElementsMatch(t, []string{"topicname1", "topicname2"}, topics.TopicNames)
37+
38+
// Log out user2
39+
session = emptyTestSession(t)
40+
url = fmt.Sprintf("/api/v1/repos/%s/%s/topics?token=%s", user2.Name, repo2.Name, token2)
41+
42+
// Test delete a topic
43+
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "Topicname1", token2)
44+
res = session.MakeRequest(t, req, http.StatusNoContent)
45+
46+
// Test add an existing topic
47+
req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "Golang", token2)
48+
res = session.MakeRequest(t, req, http.StatusNoContent)
49+
50+
// Test add a topic
51+
req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "topicName3", token2)
52+
res = session.MakeRequest(t, req, http.StatusNoContent)
53+
54+
// Test read topics using token
55+
req = NewRequest(t, "GET", url)
56+
res = session.MakeRequest(t, req, http.StatusOK)
57+
DecodeJSON(t, res, &topics)
58+
assert.ElementsMatch(t, []string{"topicname2", "golang", "topicname3"}, topics.TopicNames)
59+
60+
// Test replace topics
61+
newTopics := []string{" windows ", " ", "MAC "}
62+
req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
63+
Topics: newTopics,
64+
})
65+
res = session.MakeRequest(t, req, http.StatusNoContent)
66+
req = NewRequest(t, "GET", url)
67+
res = session.MakeRequest(t, req, http.StatusOK)
68+
DecodeJSON(t, res, &topics)
69+
assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames)
70+
71+
// Test replace topics with something invalid
72+
newTopics = []string{"topicname1", "topicname2", "topicname!"}
73+
req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
74+
Topics: newTopics,
75+
})
76+
res = session.MakeRequest(t, req, http.StatusUnprocessableEntity)
77+
req = NewRequest(t, "GET", url)
78+
res = session.MakeRequest(t, req, http.StatusOK)
79+
DecodeJSON(t, res, &topics)
80+
assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames)
81+
82+
// Test with some topics multiple times, less than 25 unique
83+
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"}
84+
req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
85+
Topics: newTopics,
86+
})
87+
res = session.MakeRequest(t, req, http.StatusNoContent)
88+
req = NewRequest(t, "GET", url)
89+
res = session.MakeRequest(t, req, http.StatusOK)
90+
DecodeJSON(t, res, &topics)
91+
assert.Equal(t, 25, len(topics.TopicNames))
92+
93+
// Test writing more topics than allowed
94+
newTopics = append(newTopics, "t26")
95+
req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
96+
Topics: newTopics,
97+
})
98+
res = session.MakeRequest(t, req, http.StatusUnprocessableEntity)
99+
100+
// Test add a topic when there is already maximum
101+
req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "t26", token2)
102+
res = session.MakeRequest(t, req, http.StatusUnprocessableEntity)
103+
104+
// Test delete a topic that repo doesn't have
105+
req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s?token=%s", user2.Name, repo2.Name, "Topicname1", token2)
106+
res = session.MakeRequest(t, req, http.StatusNotFound)
107+
108+
// Get user4's token
109+
session = loginUser(t, user4.Name)
110+
token4 := getTokenForLoggedInUser(t, session)
111+
session = emptyTestSession(t)
112+
113+
// Test read topics with write access
114+
url = fmt.Sprintf("/api/v1/repos/%s/%s/topics?token=%s", user3.Name, repo3.Name, token4)
115+
req = NewRequest(t, "GET", url)
116+
res = session.MakeRequest(t, req, http.StatusOK)
117+
DecodeJSON(t, res, &topics)
118+
assert.Equal(t, 0, len(topics.TopicNames))
119+
120+
// Test add a topic to repo with write access (requires repo admin access)
121+
req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s?token=%s", user3.Name, repo3.Name, "topicName", token4)
122+
res = session.MakeRequest(t, req, http.StatusForbidden)
123+
124+
}

models/fixtures/repo_topic.yml

+8
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,11 @@
1717
-
1818
repo_id: 33
1919
topic_id: 4
20+
21+
-
22+
repo_id: 2
23+
topic_id: 5
24+
25+
-
26+
repo_id: 2
27+
topic_id: 6

models/fixtures/topic.yml

+8
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,11 @@
1515
- id: 4
1616
name: graphql
1717
repo_count: 1
18+
19+
- id: 5
20+
name: topicname1
21+
repo_count: 1
22+
23+
- id: 6
24+
name: topicname2
25+
repo_count: 2

models/topic.go

+124-30
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,38 @@ func (err ErrTopicNotExist) Error() string {
5454
return fmt.Sprintf("topic is not exist [name: %s]", err.Name)
5555
}
5656

57-
// ValidateTopic checks topics by length and match pattern rules
57+
// ValidateTopic checks a topic by length and match pattern rules
5858
func ValidateTopic(topic string) bool {
5959
return len(topic) <= 35 && topicPattern.MatchString(topic)
6060
}
6161

62+
// SanitizeAndValidateTopics sanitizes and checks an array or topics
63+
func SanitizeAndValidateTopics(topics []string) (validTopics []string, invalidTopics []string) {
64+
validTopics = make([]string, 0)
65+
mValidTopics := make(map[string]struct{})
66+
invalidTopics = make([]string, 0)
67+
68+
for _, topic := range topics {
69+
topic = strings.TrimSpace(strings.ToLower(topic))
70+
// ignore empty string
71+
if len(topic) == 0 {
72+
continue
73+
}
74+
// ignore same topic twice
75+
if _, ok := mValidTopics[topic]; ok {
76+
continue
77+
}
78+
if ValidateTopic(topic) {
79+
validTopics = append(validTopics, topic)
80+
mValidTopics[topic] = struct{}{}
81+
} else {
82+
invalidTopics = append(invalidTopics, topic)
83+
}
84+
}
85+
86+
return validTopics, invalidTopics
87+
}
88+
6289
// GetTopicByName retrieves topic by name
6390
func GetTopicByName(name string) (*Topic, error) {
6491
var topic Topic
@@ -70,6 +97,54 @@ func GetTopicByName(name string) (*Topic, error) {
7097
return &topic, nil
7198
}
7299

100+
// addTopicByNameToRepo adds a topic name to a repo and increments the topic count.
101+
// Returns topic after the addition
102+
func addTopicByNameToRepo(e Engine, repoID int64, topicName string) (*Topic, error) {
103+
var topic Topic
104+
has, err := e.Where("name = ?", topicName).Get(&topic)
105+
if err != nil {
106+
return nil, err
107+
}
108+
if !has {
109+
topic.Name = topicName
110+
topic.RepoCount = 1
111+
if _, err := e.Insert(&topic); err != nil {
112+
return nil, err
113+
}
114+
} else {
115+
topic.RepoCount++
116+
if _, err := e.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil {
117+
return nil, err
118+
}
119+
}
120+
121+
if _, err := e.Insert(&RepoTopic{
122+
RepoID: repoID,
123+
TopicID: topic.ID,
124+
}); err != nil {
125+
return nil, err
126+
}
127+
128+
return &topic, nil
129+
}
130+
131+
// removeTopicFromRepo remove a topic from a repo and decrements the topic repo count
132+
func removeTopicFromRepo(repoID int64, topic *Topic, e Engine) error {
133+
topic.RepoCount--
134+
if _, err := e.ID(topic.ID).Cols("repo_count").Update(topic); err != nil {
135+
return err
136+
}
137+
138+
if _, err := e.Delete(&RepoTopic{
139+
RepoID: repoID,
140+
TopicID: topic.ID,
141+
}); err != nil {
142+
return err
143+
}
144+
145+
return nil
146+
}
147+
73148
// FindTopicOptions represents the options when fdin topics
74149
type FindTopicOptions struct {
75150
RepoID int64
@@ -103,6 +178,50 @@ func FindTopics(opts *FindTopicOptions) (topics []*Topic, err error) {
103178
return topics, sess.Desc("topic.repo_count").Find(&topics)
104179
}
105180

181+
// GetRepoTopicByName retrives topic from name for a repo if it exist
182+
func GetRepoTopicByName(repoID int64, topicName string) (*Topic, error) {
183+
var cond = builder.NewCond()
184+
var topic Topic
185+
cond = cond.And(builder.Eq{"repo_topic.repo_id": repoID}).And(builder.Eq{"topic.name": topicName})
186+
sess := x.Table("topic").Where(cond)
187+
sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
188+
has, err := sess.Get(&topic)
189+
if has {
190+
return &topic, err
191+
}
192+
return nil, err
193+
}
194+
195+
// AddTopic adds a topic name to a repository (if it does not already have it)
196+
func AddTopic(repoID int64, topicName string) (*Topic, error) {
197+
topic, err := GetRepoTopicByName(repoID, topicName)
198+
if err != nil {
199+
return nil, err
200+
}
201+
if topic != nil {
202+
// Repo already have topic
203+
return topic, nil
204+
}
205+
206+
return addTopicByNameToRepo(x, repoID, topicName)
207+
}
208+
209+
// DeleteTopic removes a topic name from a repository (if it has it)
210+
func DeleteTopic(repoID int64, topicName string) (*Topic, error) {
211+
topic, err := GetRepoTopicByName(repoID, topicName)
212+
if err != nil {
213+
return nil, err
214+
}
215+
if topic == nil {
216+
// Repo doesn't have topic, can't be removed
217+
return nil, nil
218+
}
219+
220+
err = removeTopicFromRepo(repoID, topic, x)
221+
222+
return topic, err
223+
}
224+
106225
// SaveTopics save topics to a repository
107226
func SaveTopics(repoID int64, topicNames ...string) error {
108227
topics, err := FindTopics(&FindTopicOptions{
@@ -152,40 +271,15 @@ func SaveTopics(repoID int64, topicNames ...string) error {
152271
}
153272

154273
for _, topicName := range addedTopicNames {
155-
var topic Topic
156-
if has, err := sess.Where("name = ?", topicName).Get(&topic); err != nil {
157-
return err
158-
} else if !has {
159-
topic.Name = topicName
160-
topic.RepoCount = 1
161-
if _, err := sess.Insert(&topic); err != nil {
162-
return err
163-
}
164-
} else {
165-
topic.RepoCount++
166-
if _, err := sess.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil {
167-
return err
168-
}
169-
}
170-
171-
if _, err := sess.Insert(&RepoTopic{
172-
RepoID: repoID,
173-
TopicID: topic.ID,
174-
}); err != nil {
274+
_, err := addTopicByNameToRepo(sess, repoID, topicName)
275+
if err != nil {
175276
return err
176277
}
177278
}
178279

179280
for _, topic := range removeTopics {
180-
topic.RepoCount--
181-
if _, err := sess.ID(topic.ID).Cols("repo_count").Update(topic); err != nil {
182-
return err
183-
}
184-
185-
if _, err := sess.Delete(&RepoTopic{
186-
RepoID: repoID,
187-
TopicID: topic.ID,
188-
}); err != nil {
281+
err := removeTopicFromRepo(repoID, topic, sess)
282+
if err != nil {
189283
return err
190284
}
191285
}

models/topic_test.go

+13-6
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ import (
1111
)
1212

1313
func TestAddTopic(t *testing.T) {
14+
totalNrOfTopics := 6
15+
repo1NrOfTopics := 3
16+
repo2NrOfTopics := 2
17+
1418
assert.NoError(t, PrepareTestDatabase())
1519

1620
topics, err := FindTopics(&FindTopicOptions{})
1721
assert.NoError(t, err)
18-
assert.EqualValues(t, 4, len(topics))
22+
assert.EqualValues(t, totalNrOfTopics, len(topics))
1923

2024
topics, err = FindTopics(&FindTopicOptions{
2125
Limit: 2,
@@ -27,33 +31,36 @@ func TestAddTopic(t *testing.T) {
2731
RepoID: 1,
2832
})
2933
assert.NoError(t, err)
30-
assert.EqualValues(t, 3, len(topics))
34+
assert.EqualValues(t, repo1NrOfTopics, len(topics))
3135

3236
assert.NoError(t, SaveTopics(2, "golang"))
37+
repo2NrOfTopics = 1
3338
topics, err = FindTopics(&FindTopicOptions{})
3439
assert.NoError(t, err)
35-
assert.EqualValues(t, 4, len(topics))
40+
assert.EqualValues(t, totalNrOfTopics, len(topics))
3641

3742
topics, err = FindTopics(&FindTopicOptions{
3843
RepoID: 2,
3944
})
4045
assert.NoError(t, err)
41-
assert.EqualValues(t, 1, len(topics))
46+
assert.EqualValues(t, repo2NrOfTopics, len(topics))
4247

4348
assert.NoError(t, SaveTopics(2, "golang", "gitea"))
49+
repo2NrOfTopics = 2
50+
totalNrOfTopics++
4451
topic, err := GetTopicByName("gitea")
4552
assert.NoError(t, err)
4653
assert.EqualValues(t, 1, topic.RepoCount)
4754

4855
topics, err = FindTopics(&FindTopicOptions{})
4956
assert.NoError(t, err)
50-
assert.EqualValues(t, 5, len(topics))
57+
assert.EqualValues(t, totalNrOfTopics, len(topics))
5158

5259
topics, err = FindTopics(&FindTopicOptions{
5360
RepoID: 2,
5461
})
5562
assert.NoError(t, err)
56-
assert.EqualValues(t, 2, len(topics))
63+
assert.EqualValues(t, repo2NrOfTopics, len(topics))
5764
}
5865

5966
func TestTopicValidator(t *testing.T) {

0 commit comments

Comments
 (0)