diff --git a/doc/api/api_resources.md b/doc/api/api_resources.md index 815bd45734c2a9d404c61017aac7b3b113b2a1a2..d496ecbca5b106e57ab1c4a891447ef785f45720 100644 --- a/doc/api/api_resources.md +++ b/doc/api/api_resources.md @@ -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` | diff --git a/doc/api/topics.md b/doc/api/topics.md new file mode 100644 index 0000000000000000000000000000000000000000..5e9e1b8fc1298c4129a6f3ab7e17473ef87890e8 --- /dev/null +++ b/doc/api/topics.md @@ -0,0 +1,190 @@ +--- +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" +``` diff --git a/lib/api/api.rb b/lib/api/api.rb index 0d5cf2792af7cf833ade3512314b66fb5d475121..dcecaeae558eb43f59b21bf34450f56a4763df96 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -284,6 +284,7 @@ class API < ::API::Base mount ::API::Tags mount ::API::Templates mount ::API::Todos + mount ::API::Topics mount ::API::Unleash mount ::API::UsageData mount ::API::UsageDataQueries diff --git a/lib/api/entities/projects/topic.rb b/lib/api/entities/projects/topic.rb new file mode 100644 index 0000000000000000000000000000000000000000..d3d1cbec81ce73dba67076c711c0e866b4dc8be3 --- /dev/null +++ b/lib/api/entities/projects/topic.rb @@ -0,0 +1,17 @@ +# 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 diff --git a/lib/api/topics.rb b/lib/api/topics.rb new file mode 100644 index 0000000000000000000000000000000000000000..bd28ebe58a9313a8cf0109820747603c7c8cbf10 --- /dev/null +++ b/lib/api/topics.rb @@ -0,0 +1,79 @@ +# 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 diff --git a/spec/lib/api/entities/projects/topic_spec.rb b/spec/lib/api/entities/projects/topic_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cdf142dbb7d6614a34bda55a0091ec7b4fd15217 --- /dev/null +++ b/spec/lib/api/entities/projects/topic_spec.rb @@ -0,0 +1,19 @@ +# 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 diff --git a/spec/requests/api/topics_spec.rb b/spec/requests/api/topics_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a5746a4022e76849d472e65bb588f7b31555b3c0 --- /dev/null +++ b/spec/requests/api/topics_spec.rb @@ -0,0 +1,217 @@ +# 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 diff --git a/spec/support/helpers/workhorse_helpers.rb b/spec/support/helpers/workhorse_helpers.rb index cd8387de686a14006798eaf0d90c850eb6f7f926..83bda6e03b13bcf53e77cf036aaf3d96e48f525c 100644 --- a/spec/support/helpers/workhorse_helpers.rb +++ b/spec/support/helpers/workhorse_helpers.rb @@ -24,7 +24,12 @@ def workhorse_internal_api_request_header # 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' }, diff --git a/workhorse/internal/upstream/routes.go b/workhorse/internal/upstream/routes.go index b4a3f85e4c416a10bb909125efaf0b71b4bb6695..22b30fe8a632f544fe1d25a2f34954a895e1a16c 100644 --- a/workhorse/internal/upstream/routes.go +++ b/workhorse/internal/upstream/routes.go @@ -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)), diff --git a/workhorse/upload_test.go b/workhorse/upload_test.go index 24c14bb12aa30ddc3ef3433614f12e0fa730d2a5..ed3859dfc981b5934c528a84539e3c4cbcccbc58 100644 --- a/workhorse/upload_test.go +++ b/workhorse/upload_test.go @@ -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},