diff options
-rw-r--r-- | app/serializers/error_tracking/project_entity.rb | 7 | ||||
-rw-r--r-- | app/serializers/error_tracking/project_serializer.rb | 7 | ||||
-rw-r--r-- | lib/gitlab/error_tracking/project.rb | 16 | ||||
-rw-r--r-- | lib/sentry/client.rb | 58 | ||||
-rw-r--r-- | spec/factories/error_tracking/project.rb | 15 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/error_tracking/list_projects.json | 13 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/error_tracking/project.json | 19 | ||||
-rw-r--r-- | spec/fixtures/sentry/list_projects_sample_response.json | 81 | ||||
-rw-r--r-- | spec/lib/sentry/client_spec.rb | 159 |
9 files changed, 334 insertions, 41 deletions
diff --git a/app/serializers/error_tracking/project_entity.rb b/app/serializers/error_tracking/project_entity.rb new file mode 100644 index 00000000000..405d87ca0d0 --- /dev/null +++ b/app/serializers/error_tracking/project_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ProjectEntity < Grape::Entity + expose(*Gitlab::ErrorTracking::Project::ACCESSORS) + end +end diff --git a/app/serializers/error_tracking/project_serializer.rb b/app/serializers/error_tracking/project_serializer.rb new file mode 100644 index 00000000000..b2406f4d631 --- /dev/null +++ b/app/serializers/error_tracking/project_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ProjectSerializer < BaseSerializer + entity ProjectEntity + end +end diff --git a/lib/gitlab/error_tracking/project.rb b/lib/gitlab/error_tracking/project.rb new file mode 100644 index 00000000000..93e81da5034 --- /dev/null +++ b/lib/gitlab/error_tracking/project.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Gitlab + module ErrorTracking + class Project + include ActiveModel::Model + + ACCESSORS = [ + :id, :name, :status, :slug, :organization_name, + :organization_id, :organization_slug + ].freeze + + attr_accessor(*ACCESSORS) + end + end +end diff --git a/lib/sentry/client.rb b/lib/sentry/client.rb index 343f2c49a7f..4187014d49e 100644 --- a/lib/sentry/client.rb +++ b/lib/sentry/client.rb @@ -3,6 +3,7 @@ module Sentry class Client Error = Class.new(StandardError) + SentryError = Class.new(StandardError) attr_accessor :url, :token @@ -16,6 +17,13 @@ module Sentry map_to_errors(issues) end + def list_projects + projects = get_projects + map_to_projects(projects) + rescue KeyError => e + raise Client::SentryError, "Sentry API response is missing keys. #{e.message}" + end + private def request_params @@ -27,18 +35,23 @@ module Sentry } end - def get_issues(issue_status:, limit:) - resp = Gitlab::HTTP.get( - issues_api_url, - **request_params.merge(query: { - query: "is:#{issue_status}", - limit: limit - }) - ) + def http_get(url, params = {}) + resp = Gitlab::HTTP.get(url, **request_params.merge(params)) handle_response(resp) end + def get_issues(issue_status:, limit:) + http_get(issues_api_url, query: { + query: "is:#{issue_status}", + limit: limit + }) + end + + def get_projects + http_get(projects_api_url) + end + def handle_response(response) unless response.code == 200 raise Client::Error, "Sentry response error: #{response.code}" @@ -47,6 +60,13 @@ module Sentry response.as_json end + def projects_api_url + projects_url = URI(@url) + projects_url.path = '/api/0/projects/' + + projects_url + end + def issues_api_url issues_url = URI(@url + '/issues/') issues_url.path.squeeze!('/') @@ -55,9 +75,11 @@ module Sentry end def map_to_errors(issues) - issues.map do |issue| - map_to_error(issue) - end + issues.map(&method(:map_to_error)) + end + + def map_to_projects(projects) + projects.map(&method(:map_to_project)) end def issue_url(id) @@ -100,5 +122,19 @@ module Sentry project_slug: project.fetch('slug', nil) ) end + + def map_to_project(project) + organization = project.fetch('organization') + + Gitlab::ErrorTracking::Project.new( + id: project.fetch('id'), + name: project.fetch('name'), + slug: project.fetch('slug'), + status: project.dig('status'), + organization_name: organization.fetch('name'), + organization_id: organization.fetch('id'), + organization_slug: organization.fetch('slug') + ) + end end end diff --git a/spec/factories/error_tracking/project.rb b/spec/factories/error_tracking/project.rb new file mode 100644 index 00000000000..5e9219b241f --- /dev/null +++ b/spec/factories/error_tracking/project.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :error_tracking_project, class: Gitlab::ErrorTracking::Project do + id '1' + name 'Sentry Example' + slug 'sentry-example' + status 'active' + organization_name 'Sentry' + organization_id '1' + organization_slug 'sentry' + + skip_create + end +end diff --git a/spec/fixtures/api/schemas/error_tracking/list_projects.json b/spec/fixtures/api/schemas/error_tracking/list_projects.json new file mode 100644 index 00000000000..2aaa525e38f --- /dev/null +++ b/spec/fixtures/api/schemas/error_tracking/list_projects.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "required": [ + "projects" + ], + "properties": { + "projects": { + "type": "array", + "items": { "$ref": "project.json" } + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/error_tracking/project.json b/spec/fixtures/api/schemas/error_tracking/project.json new file mode 100644 index 00000000000..f6d611133c7 --- /dev/null +++ b/spec/fixtures/api/schemas/error_tracking/project.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "required" : [ + "id", + "slug", + "organization_slug", + "name" + ], + "properties" : { + "id": { "type": "string"}, + "name": { "type": "string" }, + "slug": { "type": "string" }, + "status": { "type": "string" }, + "organization_name": { "type": "string" }, + "organization_slug": { "type": "string" }, + "organization_id": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/sentry/list_projects_sample_response.json b/spec/fixtures/sentry/list_projects_sample_response.json new file mode 100644 index 00000000000..fd79b0d0f30 --- /dev/null +++ b/spec/fixtures/sentry/list_projects_sample_response.json @@ -0,0 +1,81 @@ +[ + { + "status": "active", + "features": [ + "data-forwarding", + "rate-limits", + "releases" + ], + "color": "#5c3fbf", + "isInternal": false, + "isPublic": false, + "dateCreated": "2018-12-11T10:41:22.476Z", + "id": "2", + "slug": "sentry-example", + "name": "sentry-example", + "hasAccess": true, + "isBookmarked": false, + "platform": "node", + "firstEvent": "2018-12-12T15:07:18Z", + "avatar": { + "avatarUuid": null, + "avatarType": "letter_avatar" + }, + "isMember": true, + "organization": { + "status": { + "id": "active", + "name": "active" + }, + "require2FA": false, + "avatar": { + "avatarUuid": null, + "avatarType": "letter_avatar" + }, + "name": "Sentry", + "dateCreated": "2018-12-11T10:21:47.431Z", + "id": "1", + "isEarlyAdopter": false, + "slug": "sentry" + } + }, + { + "status": "active", + "features": [ + "data-forwarding", + "rate-limits" + ], + "color": "#bf873f", + "isInternal": true, + "isPublic": false, + "dateCreated": "2018-12-11T10:21:47.440Z", + "id": "1", + "slug": "internal", + "name": "Internal", + "hasAccess": true, + "isBookmarked": false, + "platform": null, + "firstEvent": "2018-12-11T10:54:35Z", + "avatar": { + "avatarUuid": null, + "avatarType": "letter_avatar" + }, + "isMember": true, + "organization": { + "status": { + "id": "active", + "name": "active" + }, + "require2FA": false, + "avatar": { + "avatarUuid": null, + "avatarType": "letter_avatar" + }, + "name": "Sentry", + "dateCreated": "2018-12-11T10:21:47.431Z", + "id": "1", + "isEarlyAdopter": false, + "slug": "sentry" + } + } +] diff --git a/spec/lib/sentry/client_spec.rb b/spec/lib/sentry/client_spec.rb index b36be0fd9c1..6fbf60a6222 100644 --- a/spec/lib/sentry/client_spec.rb +++ b/spec/lib/sentry/client_spec.rb @@ -3,30 +3,76 @@ require 'spec_helper' describe Sentry::Client do - let(:issue_status) { 'unresolved' } - let(:limit) { 20 } let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } let(:token) { 'test-token' } - let(:sample_response) do + let(:issues_sample_response) do Gitlab::Utils.deep_indifferent_access( - JSON.parse(File.read(Rails.root.join('spec/fixtures/sentry/issues_sample_response.json'))) + JSON.parse(fixture_file('sentry/issues_sample_response.json')) + ) + end + + let(:projects_sample_response) do + Gitlab::Utils.deep_indifferent_access( + JSON.parse(fixture_file('sentry/list_projects_sample_response.json')) ) end subject(:client) { described_class.new(sentry_url, token) } - describe '#list_issues' do - subject { client.list_issues(issue_status: issue_status, limit: limit) } + # Requires sentry_api_url and subject to be defined + shared_examples 'no redirects' do + let(:redirect_to) { 'https://redirected.example.com' } + let(:other_url) { 'https://other.example.org' } + + let!(:redirected_req_stub) { stub_sentry_request(other_url) } + + let!(:redirect_req_stub) do + stub_sentry_request( + sentry_api_url, + status: 302, + headers: { location: redirect_to } + ) + end - before do - stub_sentry_request(sentry_url + '/issues/?limit=20&query=is:unresolved', body: sample_response) + it 'does not follow redirects' do + expect { subject }.to raise_exception(Sentry::Client::Error, 'Sentry response error: 302') + expect(redirect_req_stub).to have_been_requested + expect(redirected_req_stub).not_to have_been_requested end + end - it 'returns objects of type ErrorTracking::Error' do - expect(subject.length).to eq(1) - expect(subject[0]).to be_a(Gitlab::ErrorTracking::Error) + shared_examples 'has correct return type' do |klass| + it "returns objects of type #{klass}" do + expect(subject).to all( be_a(klass) ) end + end + + shared_examples 'has correct length' do |length| + it { expect(subject.length).to eq(length) } + end + + # Requires sentry_api_request and subject to be defined + shared_examples 'calls sentry api' do + it 'calls sentry api' do + subject + + expect(sentry_api_request).to have_been_requested + end + end + + describe '#list_issues' do + let(:issue_status) { 'unresolved' } + let(:limit) { 20 } + + let!(:sentry_api_request) { stub_sentry_request(sentry_url + '/issues/?limit=20&query=is:unresolved', body: issues_sample_response) } + + subject { client.list_issues(issue_status: issue_status, limit: limit) } + + it_behaves_like 'calls sentry api' + + it_behaves_like 'has correct return type', Gitlab::ErrorTracking::Error + it_behaves_like 'has correct length', 1 context 'error object created from sentry response' do using RSpec::Parameterized::TableSyntax @@ -50,7 +96,7 @@ describe Sentry::Client do end with_them do - it { expect(subject[0].public_send(error_object)).to eq(sample_response[0].dig(*sentry_response)) } + it { expect(subject[0].public_send(error_object)).to eq(issues_sample_response[0].dig(*sentry_response)) } end context 'external_url' do @@ -61,24 +107,9 @@ describe Sentry::Client do end context 'redirects' do - let(:redirect_to) { 'https://redirected.example.com' } - let(:other_url) { 'https://other.example.org' } - - let!(:redirected_req_stub) { stub_sentry_request(other_url) } - - let!(:redirect_req_stub) do - stub_sentry_request( - sentry_url + '/issues/?limit=20&query=is:unresolved', - status: 302, - headers: { location: redirect_to } - ) - end + let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' } - it 'does not follow redirects' do - expect { subject }.to raise_exception(Sentry::Client::Error, 'Sentry response error: 302') - expect(redirect_req_stub).to have_been_requested - expect(redirected_req_stub).not_to have_been_requested - end + it_behaves_like 'no redirects' end # Sentry API returns 404 if there are extra slashes in the URL! @@ -99,7 +130,75 @@ describe Sentry::Client do anything ).and_call_original - client.list_issues(issue_status: issue_status, limit: limit) + subject + + expect(valid_req_stub).to have_been_requested + end + end + end + + describe '#list_projects' do + let(:sentry_list_projects_url) { 'https://sentrytest.gitlab.com/api/0/projects/' } + + let!(:sentry_api_request) { stub_sentry_request(sentry_list_projects_url, body: projects_sample_response) } + + subject { client.list_projects } + + it_behaves_like 'calls sentry api' + + it_behaves_like 'has correct return type', Gitlab::ErrorTracking::Project + it_behaves_like 'has correct length', 2 + + context 'keys missing in API response' do + it 'raises exception' do + projects_sample_response[0].delete(:slug) + + stub_sentry_request(sentry_list_projects_url, body: projects_sample_response) + + expect { subject }.to raise_error(Sentry::Client::SentryError, 'Sentry API response is missing keys. key not found: "slug"') + end + end + + context 'error object created from sentry response' do + using RSpec::Parameterized::TableSyntax + + where(:sentry_project_object, :sentry_response) do + :id | :id + :name | :name + :status | :status + :slug | :slug + :organization_name | [:organization, :name] + :organization_id | [:organization, :id] + :organization_slug | [:organization, :slug] + end + + with_them do + it { expect(subject[0].public_send(sentry_project_object)).to eq(projects_sample_response[0].dig(*sentry_response)) } + end + end + + context 'redirects' do + let(:sentry_api_url) { sentry_list_projects_url } + + it_behaves_like 'no redirects' + end + + # Sentry API returns 404 if there are extra slashes in the URL! + context 'extra slashes in URL' do + let(:sentry_url) { 'https://sentrytest.gitlab.com/api//0/projects//' } + let(:client) { described_class.new(sentry_url, token) } + + let!(:valid_req_stub) do + stub_sentry_request(sentry_list_projects_url) + end + + it 'removes extra slashes in api url' do + expect(Gitlab::HTTP).to receive(:get).with( + URI(sentry_list_projects_url), + anything + ).and_call_original + + subject expect(valid_req_stub).to have_been_requested end |