diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index ebb686c2aa7107ce7e3167629cc2b5c562582e19..f87e0c67604a4e73d55f1e75dc585e9778e1c884 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -15,6 +15,8 @@ # blocked: boolean # external: boolean # without_projects: boolean +# sort: string +# id: integer # class UsersFinder include CreatedAtFilter @@ -30,6 +32,7 @@ def initialize(current_user, params = {}) def execute users = User.all.order_id_desc users = by_username(users) + users = by_id(users) users = by_search(users) users = by_blocked(users) users = by_active(users) @@ -40,7 +43,7 @@ def execute users = by_without_projects(users) users = by_custom_attributes(users) - users + order(users) end private @@ -51,6 +54,12 @@ def by_username(users) users.by_username(params[:username]) end + def by_id(users) + return users unless params[:id] + + users.id_in(params[:id]) + end + def by_search(users) return users unless params[:search].present? @@ -102,6 +111,14 @@ def by_without_projects(users) users.without_projects end + + # rubocop: disable CodeReuse/ActiveRecord + def order(users) + return users unless params[:sort] + + users.order_by(params[:sort]) + end + # rubocop: enable CodeReuse/ActiveRecord end UsersFinder.prepend_if_ee('EE::UsersFinder') diff --git a/app/graphql/resolvers/users_resolver.rb b/app/graphql/resolvers/users_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..110a283b42edea5ebea9595a5a015057d5997b5b --- /dev/null +++ b/app/graphql/resolvers/users_resolver.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Resolvers + class UsersResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + description 'Find Users' + + argument :ids, [GraphQL::ID_TYPE], + required: false, + description: 'List of user Global IDs' + + argument :usernames, [GraphQL::STRING_TYPE], required: false, + description: 'List of usernames' + + argument :sort, Types::SortEnum, + description: 'Sort users by this criteria', + required: false, + default_value: 'created_desc' + + def resolve(ids: nil, usernames: nil, sort: nil) + authorize! + + ::UsersFinder.new(context[:current_user], finder_params(ids, usernames, sort)).execute + end + + def ready?(**args) + args = { ids: nil, usernames: nil }.merge!(args) + + return super if args.values.compact.blank? + + if args.values.all? + raise Gitlab::Graphql::Errors::ArgumentError, 'Provide either a list of usernames or ids' + end + + super + end + + def authorize! + Ability.allowed?(context[:current_user], :read_users_list) || raise_resource_not_available_error! + end + + private + + def finder_params(ids, usernames, sort) + params = {} + params[:sort] = sort if sort + params[:username] = usernames if usernames + params[:id] = parse_gids(ids) if ids + params + end + + def parse_gids(gids) + gids.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::User).model_id } + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 5184d17e94e64f171128e41935b4db415b525507..362e4004b73d702fca9a823f9cdbbadafa3b9bd7 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -52,6 +52,11 @@ class QueryType < ::Types::BaseObject description: 'Find a user', resolver: Resolvers::UserResolver + field :users, Types::UserType.connection_type, + null: true, + description: 'Find users', + resolver: Resolvers::UsersResolver + field :echo, GraphQL::STRING_TYPE, null: false, description: 'Text to echo back', resolver: Resolvers::EchoResolver diff --git a/changelogs/unreleased/215658-root-users-query.yml b/changelogs/unreleased/215658-root-users-query.yml new file mode 100644 index 0000000000000000000000000000000000000000..24c388ba826f9edbaf617f5e0fc3fae6c9567ef1 --- /dev/null +++ b/changelogs/unreleased/215658-root-users-query.yml @@ -0,0 +1,5 @@ +--- +title: Add root users query to GraphQL API +merge_request: 33195 +author: +type: added diff --git a/doc/api/graphql/index.md b/doc/api/graphql/index.md index c9e1769a1b3c3ac1005739c89965b86ec6e94a2c..d653c4e0f47fd70096373b02030448d9e1e05518 100644 --- a/doc/api/graphql/index.md +++ b/doc/api/graphql/index.md @@ -60,6 +60,7 @@ The GraphQL API includes the following queries at the root level: 1. `user` : Information about a particular user. 1. `namespace` : Within a namespace it is also possible to fetch `projects`. 1. `currentUser`: Information about the currently logged in user. +1. `users`: Information about a collection of users. 1. `metaData`: Metadata about GitLab and the GraphQL API. 1. `snippets`: Snippets visible to the currently logged in user. diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index e70f28d4b02dd71be764ba0b6d472796123dedfb..a342aa315465718639f27d785f5759bbe0d79f03 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -9559,6 +9559,46 @@ type Query { username: String ): User + """ + Find users + """ + users( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + List of user Global IDs + """ + ids: [ID!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Sort users by this criteria + """ + sort: Sort = created_desc + + """ + List of usernames + """ + usernames: [String!] + ): UserConnection + """ Vulnerabilities reported on projects on the current user's instance security dashboard """ diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index ed2631363a067d2bf2cff9a7a86c67fbb0b4836d..8a6c232f3fc7ef8ca88bed959933d3fe18246af8 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -28017,6 +28017,105 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "users", + "description": "Find users", + "args": [ + { + "name": "ids", + "description": "List of user Global IDs", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "usernames", + "description": "List of usernames", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "sort", + "description": "Sort users by this criteria", + "type": { + "kind": "ENUM", + "name": "Sort", + "ofType": null + }, + "defaultValue": "created_desc" + }, + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "UserConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "vulnerabilities", "description": "Vulnerabilities reported on projects on the current user's instance security dashboard", diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb index 7f1fc1cc1c5cba32ffaf13a3ba46fd999bdcd2f1..67c97511c3e2bf9a0e33cc9f2f930a78b57d090b 100644 --- a/spec/finders/users_finder_spec.rb +++ b/spec/finders/users_finder_spec.rb @@ -21,6 +21,12 @@ expect(users).to contain_exactly(normal_user) end + it 'filters by id' do + users = described_class.new(user, id: normal_user.id).execute + + expect(users).to contain_exactly(normal_user) + end + it 'filters by username (case insensitive)' do users = described_class.new(user, username: 'joHNdoE').execute @@ -70,6 +76,12 @@ expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user) end + + it 'orders returned results' do + users = described_class.new(user, sort: 'id_asc').execute + + expect(users).to eq([normal_user, blocked_user, omniauth_user, user]) + end end context 'with an admin user' do diff --git a/spec/graphql/resolvers/users_resolver_spec.rb b/spec/graphql/resolvers/users_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e752500d52f6c5c27b31e1fbfc44fac42ba9e97b --- /dev/null +++ b/spec/graphql/resolvers/users_resolver_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::UsersResolver do + include GraphqlHelpers + + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + + describe '#resolve' do + it 'raises an error when read_users_list is not authorized' do + expect(Ability).to receive(:allowed?).with(nil, :read_users_list).and_return(false) + + expect { resolve_users }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + + context 'when no arguments are passed' do + it 'returns all users' do + expect(resolve_users).to contain_exactly(user1, user2) + end + end + + context 'when both ids and usernames are passed ' do + it 'raises an error' do + expect { resolve_users(ids: [user1.to_global_id.to_s], usernames: [user1.username]) } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end + end + + context 'when a set of IDs is passed' do + it 'returns those users' do + expect( + resolve_users(ids: [user1.to_global_id.to_s, user2.to_global_id.to_s]) + ).to contain_exactly(user1, user2) + end + end + + context 'when a set of usernames is passed' do + it 'returns those users' do + expect( + resolve_users(usernames: [user1.username, user2.username]) + ).to contain_exactly(user1, user2) + end + end + end + + def resolve_users(args = {}) + resolve(described_class, args: args) + end +end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index 1bb04f1bb68271f370de21500fed84d66c58dad5..1194391c26a0a0d70746902496ee2ed2634f5b19 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -18,6 +18,7 @@ snippets design_management user + users ] expect(described_class).to have_graphql_fields(*expected_fields).at_least diff --git a/spec/requests/api/graphql/users_spec.rb b/spec/requests/api/graphql/users_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1e6d73cbd7d2d4835d0becda26d0c98b8b3eebf6 --- /dev/null +++ b/spec/requests/api/graphql/users_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Users' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user, created_at: 1.day.ago) } + let_it_be(:user1) { create(:user, created_at: 2.days.ago) } + let_it_be(:user2) { create(:user, created_at: 3.days.ago) } + let_it_be(:user3) { create(:user, created_at: 4.days.ago) } + + describe '.users' do + shared_examples 'a working users query' do + it_behaves_like 'a working graphql query' do + before do + post_graphql(query, current_user: current_user) + end + end + + it 'includes a list of users' do + post_graphql(query) + + expect(graphql_data.dig('users', 'nodes')).not_to be_empty + end + end + + context 'with no arguments' do + let_it_be(:query) { graphql_query_for(:users, { usernames: [user1.username] }, 'nodes { id }') } + + it_behaves_like 'a working users query' + end + + context 'with a list of usernames' do + let(:query) { graphql_query_for(:users, { usernames: [user1.username] }, 'nodes { id }') } + + it_behaves_like 'a working users query' + end + + context 'with a list of IDs' do + let(:query) { graphql_query_for(:users, { ids: [user1.to_global_id.to_s] }, 'nodes { id }') } + + it_behaves_like 'a working users query' + end + + context 'when usernames and ids parameter are used' do + let_it_be(:query) { graphql_query_for(:users, { ids: user1.to_global_id.to_s, usernames: user1.username }, 'nodes { id }') } + + it 'displays an error' do + post_graphql(query) + + expect(graphql_errors).to include( + a_hash_including('message' => a_string_matching(%r{Provide either a list of usernames or ids})) + ) + end + end + end + + describe 'sorting and pagination' do + let_it_be(:data_path) { [:users] } + + def pagination_query(params, page_info) + graphql_query_for("users", params, "#{page_info} edges { node { id } }") + end + + def pagination_results_data(data) + data.map { |user| user.dig('node', 'id') } + end + + context 'when sorting by created_at' do + let_it_be(:ascending_users) { [user3, user2, user1, current_user].map(&:to_global_id).map(&:to_s) } + + context 'when ascending' do + it_behaves_like 'sorted paginated query' do + let(:sort_param) { 'created_asc' } + let(:first_param) { 1 } + let(:expected_results) { ascending_users } + end + end + + context 'when descending' do + it_behaves_like 'sorted paginated query' do + let(:sort_param) { 'created_desc' } + let(:first_param) { 1 } + let(:expected_results) { ascending_users.reverse } + end + end + end + end +end