Commit b346f2c4 authored by Peter Leitzen's avatar Peter Leitzen

Merge branch 'feature-categories-api' into 'master'

Feature categories for API requests

See merge request gitlab-org/gitlab!45260
parents dea7a7ef 1d787b38
...@@ -22,7 +22,7 @@ class ApplicationController < ActionController::Base ...@@ -22,7 +22,7 @@ class ApplicationController < ActionController::Base
include Impersonation include Impersonation
include Gitlab::Logging::CloudflareHelper include Gitlab::Logging::CloudflareHelper
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
include ControllerWithFeatureCategory include ::Gitlab::WithFeatureCategory
before_action :authenticate_user!, except: [:route_not_found] before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms? before_action :enforce_terms!, if: :should_enforce_terms?
......
# frozen_string_literal: true
module ControllerWithFeatureCategory
extend ActiveSupport::Concern
include Gitlab::ClassAttributes
class_methods do
def feature_category(category, actions = [])
feature_category_configuration[category] ||= []
feature_category_configuration[category] += actions.map(&:to_s)
validate_config!(feature_category_configuration)
end
def feature_category_for_action(action)
category_config = feature_category_configuration.find do |_, actions|
actions.empty? || actions.include?(action)
end
category_config&.first || superclass_feature_category_for_action(action)
end
private
def validate_config!(config)
empty = config.find { |_, actions| actions.empty? }
duplicate_actions = config.values.flatten.group_by(&:itself).select { |_, v| v.count > 1 }.keys
if config.length > 1 && empty
raise ArgumentError, "#{empty.first} is defined for all actions, but other categories are set"
end
if duplicate_actions.any?
raise ArgumentError, "Actions have multiple feature categories: #{duplicate_actions.join(', ')}"
end
end
def feature_category_configuration
class_attributes[:feature_category_config] ||= {}
end
def superclass_feature_category_for_action(action)
return unless superclass.respond_to?(:feature_category_for_action)
superclass.feature_category_for_action(action)
end
end
end
...@@ -48,11 +48,17 @@ module API ...@@ -48,11 +48,17 @@ module API
before do before do
coerce_nil_params_to_array! coerce_nil_params_to_array!
api_endpoint = env['api.endpoint']
feature_category = api_endpoint.options[:for].try(:feature_category_for_app, api_endpoint).to_s
header[Gitlab::Metrics::RequestsRackMiddleware::FEATURE_CATEGORY_HEADER] = feature_category
Gitlab::ApplicationContext.push( Gitlab::ApplicationContext.push(
user: -> { @current_user }, user: -> { @current_user },
project: -> { @project }, project: -> { @project },
namespace: -> { @group }, namespace: -> { @group },
caller_id: route.origin caller_id: route.origin,
feature_category: feature_category
) )
end end
......
...@@ -2,5 +2,30 @@ ...@@ -2,5 +2,30 @@
module API module API
class Base < Grape::API::Instance # rubocop:disable API/Base class Base < Grape::API::Instance # rubocop:disable API/Base
include ::Gitlab::WithFeatureCategory
class << self
def feature_category_for_app(app)
feature_category_for_action(path_for_app(app))
end
def path_for_app(app)
normalize_path(app.namespace, app.options[:path].first)
end
def route(methods, paths = ['/'], route_options = {}, &block)
if category = route_options.delete(:feature_category)
feature_category(category, Array(paths).map { |path| normalize_path(namespace, path) })
end
super
end
private
def normalize_path(namespace, path)
[namespace.presence, path.to_s.chomp('/').presence].compact.join('/')
end
end
end end
end end
...@@ -7,10 +7,16 @@ module API ...@@ -7,10 +7,16 @@ module API
before { authenticate_by_gitlab_shell_token! } before { authenticate_by_gitlab_shell_token! }
before do before do
api_endpoint = env['api.endpoint']
feature_category = api_endpoint.options[:for].try(:feature_category_for_app, api_endpoint).to_s
header[Gitlab::Metrics::RequestsRackMiddleware::FEATURE_CATEGORY_HEADER] = feature_category
Gitlab::ApplicationContext.push( Gitlab::ApplicationContext.push(
user: -> { actor&.user }, user: -> { actor&.user },
project: -> { project }, project: -> { project },
caller_id: route.origin caller_id: route.origin,
feature_category: feature_category
) )
end end
......
...@@ -6,6 +6,8 @@ module API ...@@ -6,6 +6,8 @@ module API
helpers Helpers::IssuesHelpers helpers Helpers::IssuesHelpers
helpers Helpers::RateLimiter helpers Helpers::RateLimiter
feature_category :issue_tracking
before { authenticate_non_get! } before { authenticate_non_get! }
helpers do helpers do
......
...@@ -8,6 +8,8 @@ module API ...@@ -8,6 +8,8 @@ module API
allow_access_with_scope :read_user, if: -> (request) { request.get? } allow_access_with_scope :read_user, if: -> (request) { request.get? }
feature_category :users, ['/users/:id/custom_attributes', '/users/:id/custom_attributes/:key']
resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do
include CustomAttributesEndpoints include CustomAttributesEndpoints
...@@ -93,7 +95,7 @@ module API ...@@ -93,7 +95,7 @@ module API
use :optional_index_params_ee use :optional_index_params_ee
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get do get feature_category: :users do
authenticated_as_admin! if params[:external].present? || (params[:extern_uid].present? && params[:provider].present?) authenticated_as_admin! if params[:external].present? || (params[:extern_uid].present? && params[:provider].present?)
unless current_user&.admin? unless current_user&.admin?
...@@ -134,7 +136,7 @@ module API ...@@ -134,7 +136,7 @@ module API
use :with_custom_attributes use :with_custom_attributes
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get ":id" do get ":id", feature_category: :users do
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
not_found!('User') unless user && can?(current_user, :read_user, user) not_found!('User') unless user && can?(current_user, :read_user, user)
...@@ -149,7 +151,7 @@ module API ...@@ -149,7 +151,7 @@ module API
params do params do
requires :user_id, type: String, desc: 'The ID or username of the user' requires :user_id, type: String, desc: 'The ID or username of the user'
end end
get ":user_id/status", requirements: API::USER_REQUIREMENTS do get ":user_id/status", requirements: API::USER_REQUIREMENTS, feature_category: :users do
user = find_user(params[:user_id]) user = find_user(params[:user_id])
not_found!('User') unless user && can?(current_user, :read_user, user) not_found!('User') unless user && can?(current_user, :read_user, user)
...@@ -170,7 +172,7 @@ module API ...@@ -170,7 +172,7 @@ module API
optional :force_random_password, type: Boolean, desc: 'Flag indicating a random password will be set' optional :force_random_password, type: Boolean, desc: 'Flag indicating a random password will be set'
use :optional_attributes use :optional_attributes
end end
post do post feature_category: :users do
authenticated_as_admin! authenticated_as_admin!
params = declared_params(include_missing: false) params = declared_params(include_missing: false)
...@@ -204,7 +206,7 @@ module API ...@@ -204,7 +206,7 @@ module API
use :optional_attributes use :optional_attributes
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
put ":id" do put ":id", feature_category: :users do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params.delete(:id)) user = User.find_by(id: params.delete(:id))
...@@ -245,7 +247,7 @@ module API ...@@ -245,7 +247,7 @@ module API
requires :provider, type: String, desc: 'The external provider' requires :provider, type: String, desc: 'The external provider'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
delete ":id/identities/:provider" do delete ":id/identities/:provider", feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
...@@ -268,7 +270,7 @@ module API ...@@ -268,7 +270,7 @@ module API
optional :expires_at, type: DateTime, desc: 'The expiration date of the SSH key in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)' optional :expires_at, type: DateTime, desc: 'The expiration date of the SSH key in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
post ":id/keys" do post ":id/keys", feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params.delete(:id)) user = User.find_by(id: params.delete(:id))
...@@ -291,7 +293,7 @@ module API ...@@ -291,7 +293,7 @@ module API
requires :user_id, type: String, desc: 'The ID or username of the user' requires :user_id, type: String, desc: 'The ID or username of the user'
use :pagination use :pagination
end end
get ':user_id/keys', requirements: API::USER_REQUIREMENTS do get ':user_id/keys', requirements: API::USER_REQUIREMENTS, feature_category: :authentication_and_authorization do
user = find_user(params[:user_id]) user = find_user(params[:user_id])
not_found!('User') unless user && can?(current_user, :read_user, user) not_found!('User') unless user && can?(current_user, :read_user, user)
...@@ -307,7 +309,7 @@ module API ...@@ -307,7 +309,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the SSH key' requires :key_id, type: Integer, desc: 'The ID of the SSH key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
delete ':id/keys/:key_id' do delete ':id/keys/:key_id', feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
...@@ -332,7 +334,7 @@ module API ...@@ -332,7 +334,7 @@ module API
requires :key, type: String, desc: 'The new GPG key' requires :key, type: String, desc: 'The new GPG key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
post ':id/gpg_keys' do post ':id/gpg_keys', feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params.delete(:id)) user = User.find_by(id: params.delete(:id))
...@@ -357,7 +359,7 @@ module API ...@@ -357,7 +359,7 @@ module API
use :pagination use :pagination
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get ':id/gpg_keys' do get ':id/gpg_keys', feature_category: :authentication_and_authorization do
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
not_found!('User') unless user not_found!('User') unless user
...@@ -374,7 +376,7 @@ module API ...@@ -374,7 +376,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the GPG key' requires :key_id, type: Integer, desc: 'The ID of the GPG key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get ':id/gpg_keys/:key_id' do get ':id/gpg_keys/:key_id', feature_category: :authentication_and_authorization do
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
not_found!('User') unless user not_found!('User') unless user
...@@ -393,7 +395,7 @@ module API ...@@ -393,7 +395,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the GPG key' requires :key_id, type: Integer, desc: 'The ID of the GPG key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
delete ':id/gpg_keys/:key_id' do delete ':id/gpg_keys/:key_id', feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
...@@ -417,7 +419,7 @@ module API ...@@ -417,7 +419,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the GPG key' requires :key_id, type: Integer, desc: 'The ID of the GPG key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
post ':id/gpg_keys/:key_id/revoke' do post ':id/gpg_keys/:key_id/revoke', feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
...@@ -440,7 +442,7 @@ module API ...@@ -440,7 +442,7 @@ module API
optional :skip_confirmation, type: Boolean, desc: 'Skip confirmation of email and assume it is verified' optional :skip_confirmation, type: Boolean, desc: 'Skip confirmation of email and assume it is verified'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
post ":id/emails" do post ":id/emails", feature_category: :users do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params.delete(:id)) user = User.find_by(id: params.delete(:id))
...@@ -464,7 +466,7 @@ module API ...@@ -464,7 +466,7 @@ module API
use :pagination use :pagination
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get ':id/emails' do get ':id/emails', feature_category: :users do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
not_found!('User') unless user not_found!('User') unless user
...@@ -481,7 +483,7 @@ module API ...@@ -481,7 +483,7 @@ module API
requires :email_id, type: Integer, desc: 'The ID of the email' requires :email_id, type: Integer, desc: 'The ID of the email'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
delete ':id/emails/:email_id' do delete ':id/emails/:email_id', feature_category: :users do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
not_found!('User') unless user not_found!('User') unless user
...@@ -503,7 +505,7 @@ module API ...@@ -503,7 +505,7 @@ module API
optional :hard_delete, type: Boolean, desc: "Whether to remove a user's contributions" optional :hard_delete, type: Boolean, desc: "Whether to remove a user's contributions"
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
delete ":id" do delete ":id", feature_category: :users do
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/20757') Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/20757')
authenticated_as_admin! authenticated_as_admin!
...@@ -523,7 +525,7 @@ module API ...@@ -523,7 +525,7 @@ module API
requires :id, type: Integer, desc: 'The ID of the user' requires :id, type: Integer, desc: 'The ID of the user'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
post ':id/activate' do post ':id/activate', feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
...@@ -538,7 +540,7 @@ module API ...@@ -538,7 +540,7 @@ module API
requires :id, type: Integer, desc: 'The ID of the user' requires :id, type: Integer, desc: 'The ID of the user'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
post ':id/deactivate' do post ':id/deactivate', feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
not_found!('User') unless user not_found!('User') unless user
...@@ -564,7 +566,7 @@ module API ...@@ -564,7 +566,7 @@ module API
requires :id, type: Integer, desc: 'The ID of the user' requires :id, type: Integer, desc: 'The ID of the user'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
post ':id/block' do post ':id/block', feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
not_found!('User') unless user not_found!('User') unless user
...@@ -589,7 +591,7 @@ module API ...@@ -589,7 +591,7 @@ module API
requires :id, type: Integer, desc: 'The ID of the user' requires :id, type: Integer, desc: 'The ID of the user'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
post ':id/unblock' do post ':id/unblock', feature_category: :authentication_and_authorization do
authenticated_as_admin! authenticated_as_admin!
user = User.find_by(id: params[:id]) user = User.find_by(id: params[:id])
not_found!('User') unless user not_found!('User') unless user
...@@ -612,7 +614,7 @@ module API ...@@ -612,7 +614,7 @@ module API
optional :type, type: String, values: %w[Project Namespace] optional :type, type: String, values: %w[Project Namespace]
use :pagination use :pagination
end end
get ":user_id/memberships" do get ":user_id/memberships", feature_category: :users do
authenticated_as_admin! authenticated_as_admin!
user = find_user_by_id(params) user = find_user_by_id(params)
...@@ -656,7 +658,9 @@ module API ...@@ -656,7 +658,9 @@ module API
use :pagination use :pagination
optional :state, type: String, default: 'all', values: %w[all active inactive], desc: 'Filters (all|active|inactive) impersonation_tokens' optional :state, type: String, default: 'all', values: %w[all active inactive], desc: 'Filters (all|active|inactive) impersonation_tokens'
end end
get { present paginate(finder(declared_params(include_missing: false)).execute), with: Entities::ImpersonationToken } get feature_category :authentication_and_authorization do
present paginate(finder(declared_params(include_missing: false)).execute), with: Entities::ImpersonationToken
end
desc 'Create a impersonation token. Available only for admins.' do desc 'Create a impersonation token. Available only for admins.' do
detail 'This feature was introduced in GitLab 9.0' detail 'This feature was introduced in GitLab 9.0'
...@@ -667,7 +671,7 @@ module API ...@@ -667,7 +671,7 @@ module API
optional :expires_at, type: Date, desc: 'The expiration date in the format YEAR-MONTH-DAY of the impersonation token' optional :expires_at, type: Date, desc: 'The expiration date in the format YEAR-MONTH-DAY of the impersonation token'
optional :scopes, type: Array, desc: 'The array of scopes of the impersonation token' optional :scopes, type: Array, desc: 'The array of scopes of the impersonation token'
end end
post do post feature_category: :authentication_and_authorization do
impersonation_token = finder.build(declared_params(include_missing: false)) impersonation_token = finder.build(declared_params(include_missing: false))
if impersonation_token.save if impersonation_token.save
...@@ -684,7 +688,7 @@ module API ...@@ -684,7 +688,7 @@ module API
params do params do
requires :impersonation_token_id, type: Integer, desc: 'The ID of the impersonation token' requires :impersonation_token_id, type: Integer, desc: 'The ID of the impersonation token'
end end
get ':impersonation_token_id' do get ':impersonation_token_id', feature_category: :authentication_and_authorization do
present find_impersonation_token, with: Entities::ImpersonationToken present find_impersonation_token, with: Entities::ImpersonationToken
end end
...@@ -694,7 +698,7 @@ module API ...@@ -694,7 +698,7 @@ module API
params do params do
requires :impersonation_token_id, type: Integer, desc: 'The ID of the impersonation token' requires :impersonation_token_id, type: Integer, desc: 'The ID of the impersonation token'
end end
delete ':impersonation_token_id' do delete ':impersonation_token_id', feature_category: :authentication_and_authorization do
token = find_impersonation_token token = find_impersonation_token
destroy_conditionally!(token) do destroy_conditionally!(token) do
...@@ -716,7 +720,7 @@ module API ...@@ -716,7 +720,7 @@ module API
desc 'Get the currently authenticated user' do desc 'Get the currently authenticated user' do
success Entities::UserPublic success Entities::UserPublic
end end
get do get feature_category: :users do
entity = entity =
if current_user.admin? if current_user.admin?
Entities::UserWithAdmin Entities::UserWithAdmin
...@@ -734,7 +738,7 @@ module API ...@@ -734,7 +738,7 @@ module API
params do params do
use :pagination use :pagination
end end
get "keys" do get "keys", feature_category: :authentication_and_authorization do
keys = current_user.keys.preload_users keys = current_user.keys.preload_users
present paginate(keys), with: Entities::SSHKey present paginate(keys), with: Entities::SSHKey
...@@ -747,7 +751,7 @@ module API ...@@ -747,7 +751,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the SSH key' requires :key_id, type: Integer, desc: 'The ID of the SSH key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get "keys/:key_id" do get "keys/:key_id", feature_category: :authentication_and_authorization do
key = current_user.keys.find_by(id: params[:key_id]) key = current_user.keys.find_by(id: params[:key_id])
not_found!('Key') unless key not_found!('Key') unless key
...@@ -763,7 +767,7 @@ module API ...@@ -763,7 +767,7 @@ module API
requires :title, type: String, desc: 'The title of the new SSH key' requires :title, type: String, desc: 'The title of the new SSH key'
optional :expires_at, type: DateTime, desc: 'The expiration date of the SSH key in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)' optional :expires_at, type: DateTime, desc: 'The expiration date of the SSH key in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)'
end end
post "keys" do post "keys", feature_category: :authentication_and_authorization do
key = ::Keys::CreateService.new(current_user, declared_params(include_missing: false)).execute key = ::Keys::CreateService.new(current_user, declared_params(include_missing: false)).execute
if key.persisted? if key.persisted?
...@@ -780,7 +784,7 @@ module API ...@@ -780,7 +784,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the SSH key' requires :key_id, type: Integer, desc: 'The ID of the SSH key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
delete "keys/:key_id" do delete "keys/:key_id", feature_category: :authentication_and_authorization do
key = current_user.keys.find_by(id: params[:key_id]) key = current_user.keys.find_by(id: params[:key_id])
not_found!('Key') unless key not_found!('Key') unless key
...@@ -798,7 +802,7 @@ module API ...@@ -798,7 +802,7 @@ module API
params do params do
use :pagination use :pagination
end end
get 'gpg_keys' do get 'gpg_keys', feature_category: :authentication_and_authorization do
present paginate(current_user.gpg_keys), with: Entities::GpgKey present paginate(current_user.gpg_keys), with: Entities::GpgKey
end end
...@@ -810,7 +814,7 @@ module API ...@@ -810,7 +814,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the GPG key' requires :key_id, type: Integer, desc: 'The ID of the GPG key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get 'gpg_keys/:key_id' do get 'gpg_keys/:key_id', feature_category: :authentication_and_authorization do
key = current_user.gpg_keys.find_by(id: params[:key_id]) key = current_user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key not_found!('GPG Key') unless key
...@@ -825,7 +829,7 @@ module API ...@@ -825,7 +829,7 @@ module API
params do params do
requires :key, type: String, desc: 'The new GPG key' requires :key, type: String, desc: 'The new GPG key'
end end
post 'gpg_keys' do post 'gpg_keys', feature_category: :authentication_and_authorization do
key = ::GpgKeys::CreateService.new(current_user, declared_params(include_missing: false)).execute key = ::GpgKeys::CreateService.new(current_user, declared_params(include_missing: false)).execute
if key.persisted? if key.persisted?
...@@ -842,7 +846,7 @@ module API ...@@ -842,7 +846,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the GPG key' requires :key_id, type: Integer, desc: 'The ID of the GPG key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
post 'gpg_keys/:key_id/revoke' do post 'gpg_keys/:key_id/revoke', feature_category: :authentication_and_authorization do
key = current_user.gpg_keys.find_by(id: params[:key_id]) key = current_user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key not_found!('GPG Key') unless key
...@@ -858,7 +862,7 @@ module API ...@@ -858,7 +862,7 @@ module API
requires :key_id, type: Integer, desc: 'The ID of the SSH key' requires :key_id, type: Integer, desc: 'The ID of the SSH key'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
delete 'gpg_keys/:key_id' do delete 'gpg_keys/:key_id', feature_category: :authentication_and_authorization do
key = current_user.gpg_keys.find_by(id: params[:key_id]) key = current_user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key not_found!('GPG Key') unless key
...@@ -875,7 +879,7 @@ module API ...@@ -875,7 +879,7 @@ module API
params do params do
use :pagination use :pagination
end end
get "emails" do get "emails", feature_category: :users do
present paginate(current_user.emails), with: Entities::Email present paginate(current_user.emails), with: Entities::Email
end end
...@@ -886,7 +890,7 @@ module API ...@@ -886,7 +890,7 @@ module API
requires :email_id, type: Integer, desc: 'The ID of the email' requires :email_id, type: Integer, desc: 'The ID of the email'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get "emails/:email_id" do get "emails/:email_id", feature_category: :users do
email = current_user.emails.find_by(id: params[:email_id]) email = current_user.emails.find_by(id: params[:email_id])
not_found!('Email') unless email not_found!('Email') unless email
...@@ -900,7 +904,7 @@ module API ...@@ -900,7 +904,7 @@ module API
params do params do
requires :email, type: String, desc: 'The new email' requires :email, type: String, desc: 'The new email'
end end
post "emails" do post "emails", feature_category: :users do
email = Emails::CreateService.new(current_user, declared_params.merge(user: current_user)).execute email = Emails::CreateService.new(current_user, declared_params.merge(user: current_user)).execute
if email.errors.blank? if email.errors.blank?
...@@ -915,7 +919,7 @@ module API ...@@ -915,7 +919,7 @@ module API
requires :email_id, type: Integer, desc: 'The ID of the email' requires :email_id, type: Integer, desc: 'The ID of the email'
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
delete "emails/:email_id" do delete "emails/:email_id", feature_category: :users do
email = current_user.emails.find_by(id: params[:email_id]) email = current_user.emails.find_by(id: params[:email_id])
not_found!('Email') unless email not_found!('Email') unless email
...@@ -931,7 +935,7 @@ module API ...@@ -931,7 +935,7 @@ module API
use :pagination use :pagination
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
get "activities" do get "activities", feature_category: :users do
authenticated_as_admin! authenticated_as_admin!
activities = User activities = User
...@@ -949,7 +953,7 @@ module API ...@@ -949,7 +953,7 @@ module API
optional :emoji, type: String, desc: "The emoji to set on the status" optional :emoji, type: String, desc: "The emoji to set on the status"
optional :message, type: String, desc: "The status message to set" optional :message, type: String, desc: "The status message to set"
end end
put "status" do put "status", feature_category: :users do
forbidden! unless can?(current_user, :update_user_status, current_user) forbidden! unless can?(current_user, :update_user_status, current_user)
if ::Users::SetStatusService.new(current_user, declared_params).execute if ::Users::SetStatusService.new(current_user, declared_params).execute
...@@ -962,7 +966,7 @@ module API ...@@ -962,7 +966,7 @@ module API
desc 'get the status of the current user' do desc 'get the status of the current user' do
success Entities::UserStatus success Entities::UserStatus
end end
get 'status' do get 'status', feature_category: :users do
present current_user.status || {}, with: Entities::UserStatus present current_user.status || {}, with: Entities::UserStatus
end end
end end
......
...@@ -63,7 +63,7 @@ module Gitlab ...@@ -63,7 +63,7 @@ module Gitlab
if health_endpoint if health_endpoint
RequestsRackMiddleware.http_health_requests_total.increment(status: status, method: method) RequestsRackMiddleware.http_health_requests_total.increment(status: status, method: method)
else else
RequestsRackMiddleware.http_request_total.increment(status: status, method: method, feature_category: feature_category || FEATURE_CATEGORY_DEFAULT) RequestsRackMiddleware.http_request_total.increment(status: status, method: method, feature_category: feature_category.presence || FEATURE_CATEGORY_DEFAULT)
end end
end end
end end
......
# frozen_string_literal: true
module Gitlab
module WithFeatureCategory
extend ActiveSupport::Concern
include Gitlab::ClassAttributes
class_methods do
def feature_category(category, actions = [])
feature_category_configuration[category] ||= []
feature_category_configuration[category] += actions.map(&:to_s)
validate_config!(feature_category_configuration)
end
def feature_category_for_action(action)
category_config = feature_category_configuration.find do |_, actions|
actions.empty? || actions.include?(action)
end
category_config&.first || superclass_feature_category_for_action(action)
end
private
def validate_config!(config)
empty = config.find { |_, actions| actions.empty? }
duplicate_actions = config.values.map(&:uniq).flatten.group_by(&:itself).select { |_, v| v.count > 1 }.keys
if config.length > 1 && empty
raise ArgumentError, "#{empty.first} is defined for all actions, but other categories are set"
end
if duplicate_actions.any?
raise ArgumentError, "Actions have multiple feature categories: #{duplicate_actions.join(', ')}"
end
end
def feature_category_configuration
class_attributes[:feature_category_config] ||= {}
end
def superclass_feature_category_for_action(action)
return unless superclass.respond_to?(:feature_category_for_action)
superclass.feature_category_for_action(action)
end
end
end
end
...@@ -17,7 +17,7 @@ RSpec.describe "Every controller" do ...@@ -17,7 +17,7 @@ RSpec.describe "Every controller" do
.compact .compact
.select { |route| route[:controller].present? && route[:action].present? } .select { |route| route[:controller].present? && route[:action].present? }
.map { |route| [constantize_controller(route[:controller]), route[:action]] } .map { |route| [constantize_controller(route[:controller]), route[:action]] }
.select { |(controller, action)| controller&.include?(ControllerWithFeatureCategory) } .select { |(controller, action)| controller&.include?(::Gitlab::WithFeatureCategory) }
.reject { |(controller, action)| controller == ApplicationController || controller == Devise::UnlocksController } .reject { |(controller, action)| controller == ApplicationController || controller == Devise::UnlocksController }
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Every API endpoint' do
context 'feature categories' do
let_it_be(:feature_categories) do
YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).map(&:to_sym).to_set
end
let_it_be(:api_endpoints) do
API::API.routes.map do |route|
[route.app.options[:for], API::Base.path_for_app(route.app)]
end
end
let_it_be(:routes_without_category) do
api_endpoints.map do |(klass, path)|
next if klass.try(:feature_category_for_action, path)
# We'll add the rest in https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/463
next unless klass == ::API::Users || klass == ::API::Issues
"#{klass}##{path}"
end.compact.uniq
end
it "has feature categories" do
expect(routes_without_category).to be_empty, "#{routes_without_category} did not have a category"
end
it "recognizes the feature categories" do
routes_unknown_category = api_endpoints.map do |(klass, path)|
used_category = klass.try(:feature_category_for_action, path)
next unless used_category
next if used_category == :not_owned
[path, used_category] unless feature_categories.include?(used_category)
end.compact
expect(routes_unknown_category).to be_empty, "#{routes_unknown_category.first(10)} had an unknown category"
end
# This is required for API::Base.path_for_app to work, as it picks
# the first path
it "has no routes with multiple paths" do
routes_with_multiple_paths = API::API.routes.select { |route| route.app.options[:path].length != 1 }
failure_routes = routes_with_multiple_paths.map { |route| "#{route.app.options[:for]}:[#{route.app.options[:path].join(', ')}]" }
expect(routes_with_multiple_paths).to be_empty, "#{failure_routes} have multiple paths"
end
it "doesn't define or exclude categories on removed actions", :aggregate_failures do
api_endpoints.group_by(&:first).each do |klass, paths|
existing_paths = paths.map(&:last)
used_paths = paths_defined_in_feature_category_config(klass)
non_existing_used_paths = used_paths - existing_paths
expect(non_existing_used_paths).to be_empty,
"#{klass} used #{non_existing_used_paths} to define feature category, but the route does not exist"
end
end
end
def paths_defined_in_feature_category_config(klass)
(klass.try(:class_attributes) || {}).fetch(:feature_category_config, {})
.values
.flatten
.map(&:to_s)
end
end
...@@ -113,24 +113,38 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware do ...@@ -113,24 +113,38 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware do
end end
end end
context 'when a feature category header is present' do context 'feature category header' do
before do context 'when a feature category header is present' do
allow(app).to receive(:call).and_return([200, { described_class::FEATURE_CATEGORY_HEADER => 'issue_tracking' }, nil]) before do
end allow(app).to receive(:call).and_return([200, { described_class::FEATURE_CATEGORY_HEADER => 'issue_tracking' }, nil])
end
it 'adds the feature category to the labels for http_request_total' do it 'adds the feature category to the labels for http_request_total' do
expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get', status: 200, feature_category: 'issue_tracking') expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get', status: 200, feature_category: 'issue_tracking')
subject.call(env) subject.call(env)
end
it 'does not record a feature category for health check endpoints' do
env['PATH_INFO'] = '/-/liveness'
expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get', status: 200)
expect(described_class).not_to receive(:http_request_total)
subject.call(env)
end
end end
it 'does not record a feature category for health check endpoints' do context 'when the feature category header is an empty string' do
env['PATH_INFO'] = '/-/liveness' before do
allow(app).to receive(:call).and_return([200, { described_class::FEATURE_CATEGORY_HEADER => '' }, nil])
end
expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get', status: 200) it 'sets the feature category to unknown' do
expect(described_class).not_to receive(:http_request_total) expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get', status: 200, feature_category: 'unknown')
subject.call(env) subject.call(env)
end
end end
end end
......
# frozen_string_literal: true # frozen_string_literal: true
require 'fast_spec_helper' require 'fast_spec_helper'
require_relative "../../../app/controllers/concerns/controller_with_feature_category" require_relative "../../../lib/gitlab/with_feature_category"
RSpec.describe ControllerWithFeatureCategory do RSpec.describe Gitlab::WithFeatureCategory do
describe ".feature_category_for_action" do describe ".feature_category_for_action" do
let(:base_controller) do let(:base_controller) do
Class.new do Class.new do
include ControllerWithFeatureCategory include ::Gitlab::WithFeatureCategory
end end
end end
...@@ -56,5 +56,14 @@ RSpec.describe ControllerWithFeatureCategory do ...@@ -56,5 +56,14 @@ RSpec.describe ControllerWithFeatureCategory do
end end
end.to raise_error(ArgumentError, "Actions have multiple feature categories: world") end.to raise_error(ArgumentError, "Actions have multiple feature categories: world")
end end
it "does not raise an error when multiple calls define the same action and feature category" do
expect do
Class.new(base_controller) do
feature_category :hello, [:world]
feature_category :hello, ["world"]
end
end.not_to raise_error
end
end end
end end
...@@ -92,4 +92,36 @@ RSpec.describe API::API do ...@@ -92,4 +92,36 @@ RSpec.describe API::API do
end end
end end
end end
context 'application context' do
let_it_be(:project) { create(:project) }
let_it_be(:user) { project.owner }
it 'logs all application context fields' do
allow_any_instance_of(Gitlab::GrapeLogging::Loggers::ContextLogger).to receive(:parameters) do
Labkit::Context.current.to_h.tap do |log_context|
expect(log_context).to match('correlation_id' => an_instance_of(String),
'meta.caller_id' => '/api/:version/projects/:id/issues',
'meta.project' => project.full_path,
'meta.root_namespace' => project.namespace.full_path,
'meta.user' => user.username,
'meta.feature_category' => 'issue_tracking')
end
end
get(api("/projects/#{project.id}/issues", user))
end
it 'skips fields that do not apply' do
allow_any_instance_of(Gitlab::GrapeLogging::Loggers::ContextLogger).to receive(:parameters) do
Labkit::Context.current.to_h.tap do |log_context|
expect(log_context).to match('correlation_id' => an_instance_of(String),
'meta.caller_id' => '/api/:version/users',
'meta.feature_category' => 'users')
end
end
get(api('/users'))
end
end
end end
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