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 @@ ...@@ -15,6 +15,8 @@
# blocked: boolean # blocked: boolean
# external: boolean # external: boolean
# without_projects: boolean # without_projects: boolean
# sort: string
# id: integer
# #
class UsersFinder class UsersFinder
include CreatedAtFilter include CreatedAtFilter
...@@ -30,6 +32,7 @@ class UsersFinder ...@@ -30,6 +32,7 @@ class UsersFinder
def execute def execute
users = User.all.order_id_desc users = User.all.order_id_desc
users = by_username(users) users = by_username(users)
users = by_id(users)
users = by_search(users) users = by_search(users)
users = by_blocked(users) users = by_blocked(users)
users = by_active(users) users = by_active(users)
...@@ -40,7 +43,7 @@ class UsersFinder ...@@ -40,7 +43,7 @@ class UsersFinder
users = by_without_projects(users) users = by_without_projects(users)
users = by_custom_attributes(users) users = by_custom_attributes(users)
users order(users)
end end
private private
...@@ -51,6 +54,12 @@ class UsersFinder ...@@ -51,6 +54,12 @@ class UsersFinder
users.by_username(params[:username]) users.by_username(params[:username])
end end
def by_id(users)
return users unless params[:id]
users.id_in(params[:id])
end
def by_search(users) def by_search(users)
return users unless params[:search].present? return users unless params[:search].present?
...@@ -102,6 +111,14 @@ class UsersFinder ...@@ -102,6 +111,14 @@ class UsersFinder
users.without_projects users.without_projects
end end
# rubocop: disable CodeReuse/ActiveRecord
def order(users)
return users unless params[:sort]
users.order_by(params[:sort])
end
# rubocop: enable CodeReuse/ActiveRecord
end end
UsersFinder.prepend_if_ee('EE::UsersFinder') 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 ...@@ -52,6 +52,11 @@ module Types
description: 'Find a user', description: 'Find a user',
resolver: Resolvers::UserResolver resolver: Resolvers::UserResolver
field :users, Types::UserType.connection_type,
null: true,
description: 'Find users',
resolver: Resolvers::UsersResolver
field :echo, GraphQL::STRING_TYPE, null: false, field :echo, GraphQL::STRING_TYPE, null: false,
description: 'Text to echo back', description: 'Text to echo back',
resolver: Resolvers::EchoResolver 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: ...@@ -60,6 +60,7 @@ The GraphQL API includes the following queries at the root level:
1. `user` : Information about a particular user. 1. `user` : Information about a particular user.
1. `namespace` : Within a namespace it is also possible to fetch `projects`. 1. `namespace` : Within a namespace it is also possible to fetch `projects`.
1. `currentUser`: Information about the currently logged in user. 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. `metaData`: Metadata about GitLab and the GraphQL API.
1. `snippets`: Snippets visible to the currently logged in user. 1. `snippets`: Snippets visible to the currently logged in user.
......
...@@ -9635,6 +9635,46 @@ type Query { ...@@ -9635,6 +9635,46 @@ type Query {
username: String username: String
): User ): 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 Vulnerabilities reported on projects on the current user's instance security dashboard
""" """
......
...@@ -28307,6 +28307,105 @@ ...@@ -28307,6 +28307,105 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "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", "name": "vulnerabilities",
"description": "Vulnerabilities reported on projects on the current user's instance security dashboard", "description": "Vulnerabilities reported on projects on the current user's instance security dashboard",
...@@ -21,6 +21,12 @@ describe UsersFinder do ...@@ -21,6 +21,12 @@ describe UsersFinder do
expect(users).to contain_exactly(normal_user) expect(users).to contain_exactly(normal_user)
end 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 it 'filters by username (case insensitive)' do
users = described_class.new(user, username: 'joHNdoE').execute users = described_class.new(user, username: 'joHNdoE').execute
...@@ -70,6 +76,12 @@ describe UsersFinder do ...@@ -70,6 +76,12 @@ describe UsersFinder do
expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user) expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user)
end 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 end
context 'with an admin user' do 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 ...@@ -18,6 +18,7 @@ describe GitlabSchema.types['Query'] do
snippets snippets
design_management design_management
user user
users
] ]
expect(described_class).to have_graphql_fields(*expected_fields).at_least 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