Commit 13979ec3 authored by Oswaldo Ferreira's avatar Oswaldo Ferreira

Merge branch '215658-root-users-query' into 'master'

Add root users query to GraphQL API

See merge request gitlab-org/gitlab!33195
parents fd8bb846 09a6bc1b
...@@ -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