Commit 09a6bc1b authored by Max Woolf's avatar Max Woolf Committed by Oswaldo Ferreira

Add root users query to GraphQL API

Users can now query the GraphQL API
to pull out an entire list of users,
or filter by a subset of IDs or
usernames
parent 5b65045d
......@@ -15,6 +15,8 @@
# blocked: boolean
# external: boolean
# without_projects: boolean
# sort: string
# id: integer
#
class UsersFinder
include CreatedAtFilter
......@@ -30,6 +32,7 @@ class UsersFinder
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 @@ class UsersFinder
users = by_without_projects(users)
users = by_custom_attributes(users)
users
order(users)
end
private
......@@ -51,6 +54,12 @@ class UsersFinder
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 @@ class UsersFinder
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')
# 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
......@@ -52,6 +52,11 @@ module Types
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
......
---
title: Add root users query to GraphQL API
merge_request: 33195
author:
type: added
......@@ -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.
......
......@@ -9635,6 +9635,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
"""
......
......@@ -28307,6 +28307,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",
......@@ -21,6 +21,12 @@ describe UsersFinder do
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 @@ describe UsersFinder do
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
......
# 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
......@@ -18,6 +18,7 @@ describe GitlabSchema.types['Query'] do
snippets
design_management
user
users
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
......
# 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
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