Commit 0488dd17 authored by Jonas Wälter's avatar Jonas Wälter Committed by Heinrich Lee Yu

API: Add Topics API

parent 79e60c25
......@@ -171,6 +171,7 @@ The following API resources are available outside of project and group contexts
| [Suggestions](suggestions.md) | `/suggestions` |
| [System hooks](system_hooks.md) | `/hooks` |
| [To-dos](todos.md) | `/todos` |
| [Topics](topics.md) | `/topics` |
| [Service Data](usage_data.md) | `/usage_data` (For GitLab instance [Administrator](../user/permissions.md) users only) |
| [Users](users.md) | `/users` |
| [Validate `.gitlab-ci.yml` file](lint.md) | `/lint` |
......
---
stage: Manage
group: Workspace
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# Topics API **(FREE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/340920) in GitLab 14.5.
Interact with project topics using the REST API.
## List topics
Returns a list of project topics in the GitLab instance ordered by number of associated projects.
```plaintext
GET /topics
```
Supported attributes:
| Attribute | Type | Required | Description |
| ---------- | ------- | ---------------------- | ----------- |
| `page` | integer | **{dotted-circle}** No | Page to retrieve. Defaults to `1`. |
| `per_page` | integer | **{dotted-circle}** No | Number of records to return per page. Defaults to `20`. |
| `search` | string | **{dotted-circle}** No | Search topics against their `name`. |
Example request:
```shell
curl "https://gitlab.example.com/api/v4/topics?search=git"
```
Example response:
```json
[
{
"id": 1,
"name": "GitLab",
"description": "GitLab is an open source end-to-end software development platform with built-in version control, issue tracking, code review, CI/CD, and more.",
"total_projects_count": 1000,
"avatar_url": "http://www.gravatar.com/avatar/a0d477b3ea21970ce6ffcbb817b0b435?s=80&d=identicon"
},
{
"id": 3,
"name": "Git",
"description": "Git is a free and open source distributed version control system designed to handle everything from small to very large projects with speed and efficiency.",
"total_projects_count": 900,
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
},
{
"id": 2,
"name": "Git LFS",
"description": null,
"total_projects_count": 300,
"avatar_url": null
}
]
```
## Get a topic
Get a project topic by ID.
```plaintext
GET /topics/:id
```
Supported attributes:
| Attribute | Type | Required | Description |
| --------- | ------- | ---------------------- | ------------------- |
| `id` | integer | **{check-circle}** Yes | ID of project topic |
Example request:
```shell
curl "https://gitlab.example.com/api/v4/topics/1"
```
Example response:
```json
{
"id": 1,
"name": "GitLab",
"description": "GitLab is an open source end-to-end software development platform with built-in version control, issue tracking, code review, CI/CD, and more.",
"total_projects_count": 1000,
"avatar_url": "http://www.gravatar.com/avatar/a0d477b3ea21970ce6ffcbb817b0b435?s=80&d=identicon"
}
```
## List projects assigned to a topic
Use the [Projects API](projects.md#list-all-projects) to list all projects assigned to a specific topic.
```plaintext
GET /projects?topic=<topic_name>
```
## Create a project topic
Create a new project topic. Only available to administrators.
```plaintext
POST /topics
```
Supported attributes:
| Attribute | Type | Required | Description |
| ------------- | ------- | ---------------------- | ----------- |
| `name` | string | **{check-circle}** Yes | Name |
| `avatar` | file | **{dotted-circle}** No | Avatar |
| `description` | string | **{dotted-circle}** No | Description |
Example request:
```shell
curl --request POST \
--data "name=topic1" \
--header "PRIVATE-TOKEN: <your_access_token>" \
"https://gitlab.example.com/api/v4/topics"
```
Example response:
```json
{
"id": 1,
"name": "topic1",
"description": null,
"total_projects_count": 0,
"avatar_url": null
}
```
## Update a project topic
Update a project topic. Only available to administrators.
```plaintext
PUT /topics/:id
```
Supported attributes:
| Attribute | Type | Required | Description |
| ------------- | ------- | ---------------------- | ------------------- |
| `id` | integer | **{check-circle}** Yes | ID of project topic |
| `avatar` | file | **{dotted-circle}** No | Avatar |
| `description` | string | **{dotted-circle}** No | Description |
| `name` | string | **{dotted-circle}** No | Name |
Example request:
```shell
curl --request PUT \
--data "name=topic1" \
--header "PRIVATE-TOKEN: <your_access_token>" \
"https://gitlab.example.com/api/v4/topics/1"
Example response:
```json
{
"id": 1,
"name": "topic1",
"description": null,
"total_projects_count": 0,
"avatar_url": null
}
```
### Upload a topic avatar
To upload an avatar file from your file system, use the `--form` argument. This argument causes
cURL to post data using the header `Content-Type: multipart/form-data`. The
`file=` parameter must point to a file on your file system and be preceded by
`@`. For example:
```shell
curl --request PUT \
--header "PRIVATE-TOKEN: <your_access_token>" \
"https://gitlab.example.com/api/v4/topics/1" \
--form "avatar=@/tmp/example.png"
```
......@@ -284,6 +284,7 @@ module API
mount ::API::Tags
mount ::API::Templates
mount ::API::Todos
mount ::API::Topics
mount ::API::Unleash
mount ::API::UsageData
mount ::API::UsageDataQueries
......
# frozen_string_literal: true
module API
module Entities
module Projects
class Topic < Grape::Entity
expose :id
expose :name
expose :description
expose :total_projects_count
expose :avatar_url do |topic, options|
topic.avatar_url(only_path: false)
end
end
end
end
end
# frozen_string_literal: true
module API
class Topics < ::API::Base
include PaginationParams
feature_category :projects
desc 'Get topics' do
detail 'This feature was introduced in GitLab 14.5.'
success Entities::Projects::Topic
end
params do
optional :search, type: String, desc: 'Return list of topics matching the search criteria'
use :pagination
end
get 'topics' do
topics = ::Projects::TopicsFinder.new(params: declared_params(include_missing: false)).execute
present paginate(topics), with: Entities::Projects::Topic
end
desc 'Get topic' do
detail 'This feature was introduced in GitLab 14.5.'
success Entities::Projects::Topic
end
params do
requires :id, type: Integer, desc: 'ID of project topic'
end
get 'topics/:id' do
topic = ::Projects::Topic.find(params[:id])
present topic, with: Entities::Projects::Topic
end
desc 'Create a topic' do
detail 'This feature was introduced in GitLab 14.5.'
success Entities::Projects::Topic
end
params do
requires :name, type: String, desc: 'Name'
optional :description, type: String, desc: 'Description'
optional :avatar, type: ::API::Validations::Types::WorkhorseFile, desc: 'Avatar image for topic'
end
post 'topics' do
authenticated_as_admin!
topic = ::Projects::Topic.new(declared_params(include_missing: false))
if topic.save
present topic, with: Entities::Projects::Topic
else
render_validation_error!(topic)
end
end
desc 'Update a topic' do
detail 'This feature was introduced in GitLab 14.5.'
success Entities::Projects::Topic
end
params do
requires :id, type: Integer, desc: 'ID of project topic'
optional :name, type: String, desc: 'Name'
optional :description, type: String, desc: 'Description'
optional :avatar, type: ::API::Validations::Types::WorkhorseFile, desc: 'Avatar image for topic'
end
put 'topics/:id' do
authenticated_as_admin!
topic = ::Projects::Topic.find(params[:id])
if topic.update(declared_params(include_missing: false))
present topic, with: Entities::Projects::Topic
else
render_validation_error!(topic)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Entities::Projects::Topic do
let(:topic) { create(:topic) }
subject { described_class.new(topic).as_json }
it 'exposes correct attributes' do
expect(subject).to include(
:id,
:name,
:description,
:total_projects_count,
:avatar_url
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Topics do
include WorkhorseHelpers
let_it_be(:topic_1) { create(:topic, name: 'Git', total_projects_count: 1) }
let_it_be(:topic_2) { create(:topic, name: 'GitLab', total_projects_count: 2) }
let_it_be(:topic_3) { create(:topic, name: 'other-topic', total_projects_count: 3) }
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:user) { create(:user) }
let(:file) { fixture_file_upload('spec/fixtures/dk.png') }
describe 'GET /topics', :aggregate_failures do
it 'returns topics ordered by total_projects_count' do
get api('/topics')
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(3)
expect(json_response[0]['id']).to eq(topic_3.id)
expect(json_response[0]['name']).to eq('other-topic')
expect(json_response[0]['total_projects_count']).to eq(3)
expect(json_response[1]['id']).to eq(topic_2.id)
expect(json_response[1]['name']).to eq('GitLab')
expect(json_response[1]['total_projects_count']).to eq(2)
expect(json_response[2]['id']).to eq(topic_1.id)
expect(json_response[2]['name']).to eq('Git')
expect(json_response[2]['total_projects_count']).to eq(1)
end
context 'with search' do
using RSpec::Parameterized::TableSyntax
where(:search, :result) do
'' | %w[other-topic GitLab Git]
'g' | %w[]
'gi' | %w[]
'git' | %w[Git GitLab]
'x' | %w[]
0 | %w[]
end
with_them do
it 'returns filtered topics' do
get api('/topics'), params: { search: search }
expect(json_response.map { |t| t['name'] }).to eq(result)
end
end
end
context 'with pagination' do
using RSpec::Parameterized::TableSyntax
where(:params, :result) do
{ page: 0 } | %w[other-topic GitLab Git]
{ page: 1 } | %w[other-topic GitLab Git]
{ page: 2 } | %w[]
{ per_page: 1 } | %w[other-topic]
{ per_page: 2 } | %w[other-topic GitLab]
{ per_page: 3 } | %w[other-topic GitLab Git]
{ page: 0, per_page: 1 } | %w[other-topic]
{ page: 0, per_page: 2 } | %w[other-topic GitLab]
{ page: 1, per_page: 1 } | %w[other-topic]
{ page: 1, per_page: 2 } | %w[other-topic GitLab]
{ page: 2, per_page: 1 } | %w[GitLab]
{ page: 2, per_page: 2 } | %w[Git]
{ page: 3, per_page: 1 } | %w[Git]
{ page: 3, per_page: 2 } | %w[]
{ page: 4, per_page: 1 } | %w[]
{ page: 4, per_page: 2 } | %w[]
end
with_them do
it 'returns paginated topics' do
get api('/topics'), params: params
expect(json_response.map { |t| t['name'] }).to eq(result)
end
end
end
end
describe 'GET /topic/:id', :aggregate_failures do
it 'returns topic' do
get api("/topics/#{topic_2.id}")
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq(topic_2.id)
expect(json_response['name']).to eq('GitLab')
expect(json_response['total_projects_count']).to eq(2)
end
it 'returns 404 for non existing id' do
get api("/topics/#{non_existing_record_id}")
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns 400 for invalid `id` parameter' do
get api('/topics/invalid')
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eql('id is invalid')
end
end
describe 'POST /topics', :aggregate_failures do
context 'as administrator' do
it 'creates a topic' do
post api('/topics/', admin), params: { name: 'my-topic' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq('my-topic')
expect(Projects::Topic.find(json_response['id']).name).to eq('my-topic')
end
it 'creates a topic with avatar and description' do
workhorse_form_with_file(
api('/topics/', admin),
file_key: :avatar,
params: { name: 'my-topic', description: 'my description...', avatar: file }
)
expect(response).to have_gitlab_http_status(:created)
expect(json_response['description']).to eq('my description...')
expect(json_response['avatar_url']).to end_with('dk.png')
end
it 'returns 400 if name is missing' do
post api('/topics/', admin)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eql('name is missing')
end
end
context 'as normal user' do
it 'returns 403 Forbidden' do
post api('/topics/', user), params: { name: 'my-topic' }
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'as anonymous' do
it 'returns 401 Unauthorized' do
post api('/topics/'), params: { name: 'my-topic' }
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
end
describe 'PUT /topics', :aggregate_failures do
context 'as administrator' do
it 'updates a topic' do
put api("/topics/#{topic_3.id}", admin), params: { name: 'my-topic' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['name']).to eq('my-topic')
expect(topic_3.reload.name).to eq('my-topic')
end
it 'updates a topic with avatar and description' do
workhorse_form_with_file(
api("/topics/#{topic_3.id}", admin),
method: :put,
file_key: :avatar,
params: { description: 'my description...', avatar: file }
)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['description']).to eq('my description...')
expect(json_response['avatar_url']).to end_with('dk.png')
end
it 'returns 404 for non existing id' do
put api("/topics/#{non_existing_record_id}", admin), params: { name: 'my-topic' }
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns 400 for invalid `id` parameter' do
put api('/topics/invalid', admin), params: { name: 'my-topic' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eql('id is invalid')
end
end
context 'as normal user' do
it 'returns 403 Forbidden' do
put api("/topics/#{topic_3.id}", user), params: { name: 'my-topic' }
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'as anonymous' do
it 'returns 401 Unauthorized' do
put api("/topics/#{topic_3.id}"), params: { name: 'my-topic' }
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
end
end
......@@ -24,7 +24,12 @@ module WorkhorseHelpers
# workhorse_post_with_file will transform file_key inside params as if it was disk accelerated by workhorse
def workhorse_post_with_file(url, file_key:, params:)
workhorse_request_with_file(:post, url,
workhorse_form_with_file(url, method: :post, file_key: file_key, params: params)
end
# workhorse_form_with_file will transform file_key inside params as if it was disk accelerated by workhorse
def workhorse_form_with_file(url, file_key:, params:, method: :post)
workhorse_request_with_file(method, url,
file_key: file_key,
params: params,
env: { 'CONTENT_TYPE' => 'multipart/form-data' },
......
......@@ -60,6 +60,7 @@ const (
geoGitProjectPattern = `^/[^-].+\.git/` // Prevent matching routes like /-/push_from_secondary
projectPattern = `^/([^/]+/){1,}[^/]+/`
apiProjectPattern = apiPattern + `v4/projects/[^/]+/` // API: Projects can be encoded via group%2Fsubgroup%2Fproject
apiTopicPattern = apiPattern + `v4/topics`
snippetUploadPattern = `^/uploads/personal_snippet`
userUploadPattern = `^/uploads/user`
importPattern = `^/import/`
......@@ -295,6 +296,8 @@ func configureRoutes(u *upstream) {
// Overall status can be seen at https://gitlab.com/groups/gitlab-org/-/epics/1802#current-status
u.route("POST", apiProjectPattern+`wikis/attachments\z`, uploadAccelerateProxy),
u.route("POST", apiPattern+`graphql\z`, uploadAccelerateProxy),
u.route("POST", apiTopicPattern, uploadAccelerateProxy),
u.route("PUT", apiTopicPattern, uploadAccelerateProxy),
u.route("POST", apiPattern+`v4/groups/import`, upload.Accelerate(api, signingProxy, preparers.uploads)),
u.route("POST", apiPattern+`v4/projects/import`, upload.Accelerate(api, signingProxy, preparers.uploads)),
......
......@@ -123,6 +123,8 @@ func TestAcceleratedUpload(t *testing.T) {
{"POST", `/api/v4/projects/group%2Fproject/wikis/attachments`, false},
{"POST", `/api/v4/projects/group%2Fsubgroup%2Fproject/wikis/attachments`, false},
{"POST", `/api/graphql`, false},
{"POST", `/api/v4/topics`, false},
{"PUT", `/api/v4/topics`, false},
{"PUT", "/api/v4/projects/9001/packages/nuget/v1/files", true},
{"PUT", "/api/v4/projects/group%2Fproject/packages/nuget/v1/files", true},
{"PUT", "/api/v4/projects/group%2Fsubgroup%2Fproject/packages/nuget/v1/files", true},
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment