Commit f1aea54b authored by Jan Provaznik's avatar Jan Provaznik

List requirement in GraphQL API

Allows listing requirements through GraphQL API and use basic
filtering and sorting parameters.
parent 392e50ff
......@@ -6001,6 +6001,76 @@ type Project {
"""
requestAccessEnabled: Boolean
"""
Find a single requirement. Available only when feature flag `requirements_management` is enabled.
"""
requirement(
"""
IID of the requirement, e.g., "1"
"""
iid: ID
"""
List of IIDs of requirements, e.g., [1, 2]
"""
iids: [ID!]
"""
List requirements by sort order
"""
sort: Sort
"""
Filter requirements by state
"""
state: RequirementState
): Requirement
"""
Find requirements. Available only when feature flag `requirements_management` is enabled.
"""
requirements(
"""
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
"""
IID of the requirement, e.g., "1"
"""
iid: ID
"""
List of IIDs of requirements, e.g., [1, 2]
"""
iids: [ID!]
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
List requirements by sort order
"""
sort: Sort
"""
Filter requirements by state
"""
state: RequirementState
): RequirementConnection
"""
Detailed version of a Sentry error on the project
"""
......@@ -6665,6 +6735,41 @@ type Requirement {
userPermissions: RequirementPermissions!
}
"""
The connection type for Requirement.
"""
type RequirementConnection {
"""
A list of edges.
"""
edges: [RequirementEdge]
"""
A list of nodes.
"""
nodes: [Requirement]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type RequirementEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Requirement
}
"""
Check permissions for the current user on a requirement
"""
......@@ -7464,6 +7569,31 @@ type SnippetPermissions {
updateSnippet: Boolean!
}
"""
Common sort values
"""
enum Sort {
"""
Created at ascending order
"""
created_asc
"""
Created at descending order
"""
created_desc
"""
Updated at ascending order
"""
updated_asc
"""
Updated at descending order
"""
updated_desc
}
type Submodule implements Entry {
"""
Flat path of the entry
......
......@@ -18032,6 +18032,168 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "requirement",
"description": "Find a single requirement. Available only when feature flag `requirements_management` is enabled.",
"args": [
{
"name": "iid",
"description": "IID of the requirement, e.g., \"1\"",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "iids",
"description": "List of IIDs of requirements, e.g., [1, 2]",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "sort",
"description": "List requirements by sort order",
"type": {
"kind": "ENUM",
"name": "Sort",
"ofType": null
},
"defaultValue": null
},
{
"name": "state",
"description": "Filter requirements by state",
"type": {
"kind": "ENUM",
"name": "RequirementState",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Requirement",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "requirements",
"description": "Find requirements. Available only when feature flag `requirements_management` is enabled.",
"args": [
{
"name": "iid",
"description": "IID of the requirement, e.g., \"1\"",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{
"name": "iids",
"description": "List of IIDs of requirements, e.g., [1, 2]",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
}
},
"defaultValue": null
},
{
"name": "sort",
"description": "List requirements by sort order",
"type": {
"kind": "ENUM",
"name": "Sort",
"ofType": null
},
"defaultValue": null
},
{
"name": "state",
"description": "Filter requirements by state",
"type": {
"kind": "ENUM",
"name": "RequirementState",
"ofType": null
},
"defaultValue": null
},
{
"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": "RequirementConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sentryDetailedError",
"description": "Detailed version of a Sentry error on the project",
......@@ -20106,6 +20268,118 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "RequirementConnection",
"description": "The connection type for Requirement.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "RequirementEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Requirement",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "RequirementEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Requirement",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "RequirementPermissions",
......@@ -22643,6 +22917,41 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "Sort",
"description": "Common sort values",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "updated_desc",
"description": "Updated at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updated_asc",
"description": "Updated at ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "created_desc",
"description": "Created at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "created_asc",
"description": "Created at ascending order",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "String",
......
......@@ -898,6 +898,7 @@ Information about pagination in a connection.
| `removeSourceBranchAfterMerge` | Boolean | Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project |
| `repository` | Repository | Git repository of the project |
| `requestAccessEnabled` | Boolean | Indicates if users can request member access to the project |
| `requirement` | Requirement | Find a single requirement. Available only when feature flag `requirements_management` is enabled. |
| `sentryDetailedError` | SentryDetailedError | Detailed version of a Sentry error on the project |
| `sentryErrors` | SentryErrorCollection | Paginated collection of Sentry errors on the project |
| `serviceDeskAddress` | String | E-mail address of the service desk. |
......
# frozen_string_literal: true
class RequirementsFinder
include Gitlab::Utils::StrongMemoize
# Params:
# project_id: integer
# iids: integer[]
# state: string[]
# sort: string
def initialize(current_user, params = {})
@current_user = current_user
@params = params
end
def execute
items = init_collection
items = by_state(items)
items = by_iid(items)
sort(items)
end
private
attr_reader :current_user, :params
def init_collection
return Requirement.none unless Ability.allowed?(current_user, :read_requirement, project)
project.requirements
end
def by_iid(items)
return items unless params[:iids].present?
items.for_iid(params[:iids])
end
def by_state(items)
return items unless params[:state].present?
items.for_state(params[:state])
end
def project
strong_memoize(:project) do
::Project.find_by_id(params[:project_id]) if params[:project_id].present?
end
end
def sort(items)
sorts = Requirement.simple_sorts.keys
sort = sorts.include?(params[:sort]) ? params[:sort] : 'id_desc'
items.order_by(sort)
end
end
......@@ -7,10 +7,10 @@ module EE
prepended do
field :service_desk_enabled, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if the project has service desk enabled.'
description: 'Indicates if the project has service desk enabled.'
field :service_desk_address, GraphQL::STRING_TYPE, null: true,
description: 'E-mail address of the service desk.'
description: 'E-mail address of the service desk.'
field :vulnerabilities,
::Types::VulnerabilityType.connection_type,
......@@ -18,6 +18,15 @@ module EE
description: 'Vulnerabilities reported on the project',
resolver: Resolvers::VulnerabilitiesResolver,
feature_flag: :first_class_vulnerabilities
field :requirement, ::Types::RequirementType, null: true,
description: 'Find a single requirement. Available only when feature flag `requirements_management` is enabled.',
resolver: ::Resolvers::RequirementsResolver.single
field :requirements, ::Types::RequirementType.connection_type, null: true,
description: 'Find requirements. Available only when feature flag `requirements_management` is enabled.',
max_page_size: 2000,
resolver: ::Resolvers::RequirementsResolver
end
end
end
......
# frozen_string_literal: true
module Resolvers
class RequirementsResolver < BaseResolver
argument :iid, GraphQL::ID_TYPE,
required: false,
description: 'IID of the requirement, e.g., "1"'
argument :iids, [GraphQL::ID_TYPE],
required: false,
description: 'List of IIDs of requirements, e.g., [1, 2]'
argument :sort, Types::SortEnum,
required: false,
description: 'List requirements by sort order'
argument :state, Types::RequirementStateEnum,
required: false,
description: 'Filter requirements by state'
type Types::RequirementType, null: true
def resolve(**args)
# The project could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the project to query for issues, so
# make sure it's loaded and not `nil` before continuing.
project = object.respond_to?(:sync) ? object.sync : object
return Requirement.none if project.nil?
return Requirement.none unless Feature.enabled?(:requirements_management, project)
args[:project_id] = project.id
args[:iids] ||= [args[:iid]].compact
RequirementsFinder.new(context[:current_user], args).execute
end
end
end
......@@ -4,6 +4,7 @@ class Requirement < ApplicationRecord
include CacheMarkdownField
include StripAttribute
include AtomicInternalId
include Sortable
cache_markdown_field :title, pipeline: :single_line
......@@ -21,6 +22,13 @@ class Requirement < ApplicationRecord
enum state: { opened: 1, archived: 2 }
scope :for_iid, -> (iid) { where(iid: iid) }
scope :for_state, -> (state) { where(state: state) }
def self.simple_sorts
super.except('name_asc', 'name_desc')
end
# In the next iteration we will support also group-level requirements
# so it's better to use resource_parent instead of project directly
def resource_parent
......
# frozen_string_literal: true
require 'spec_helper'
describe RequirementsFinder do
let_it_be(:project) { create(:project) }
let_it_be(:project_user) { create(:user).tap { |u| project.add_developer(u) } }
let_it_be(:requirement1) { create(:requirement, project: project, state: 'opened', updated_at: 3.days.ago) }
let_it_be(:requirement2) { create(:requirement, project: project, state: 'opened', updated_at: 1.day.ago) }
let_it_be(:requirement3) { create(:requirement, project: project, state: 'archived', updated_at: 2.days.ago) }
let_it_be(:requirement4) { create(:requirement, state: 'opened') }
subject { described_class.new(project_user, params).execute }
describe '#execute' do
context 'when requirements are enabled' do
before do
stub_licensed_features(requirements: true)
end
context 'when project is not set' do
let(:params) { {} }
it 'does not return any requirements' do
is_expected.to be_empty
end
end
context 'when project is set' do
let(:params) { { project_id: project.id } }
it 'returns all requirements in the project' do
is_expected.to match_array([requirement1, requirement2, requirement3])
end
end
context 'when state is set' do
let(:params) { { project_id: project.id, state: 'opened' } }
it 'returns matched requirements' do
is_expected.to match_array([requirement1, requirement2])
end
end
context 'when iid is set' do
let(:params) { { project_id: project.id, iids: [requirement2.iid, requirement3.iid] } }
it 'returns matched requirements' do
is_expected.to match_array([requirement2, requirement3])
end
end
context 'when user can not read requirements in the project' do
let(:user) { create(:user) }
let(:params) { { project_id: project.id } }
it 'does not return any requirements' do
expect(described_class.new(user, params).execute).to be_empty
end
end
describe 'ordering' do
using RSpec::Parameterized::TableSyntax
let(:params) { { project_id: project.id, sort: sort } }
where(:sort, :ordered_requirements) do
'id_asc' | [:requirement1, :requirement2, :requirement3]
'id_desc' | [:requirement3, :requirement2, :requirement1]
'updated_at_asc' | [:requirement1, :requirement3, :requirement2]
'updated_at_desc' | [:requirement2, :requirement3, :requirement1]
'err' | [:requirement3, :requirement2, :requirement1]
end
with_them do
it 'returns the requirements ordered' do
expect(subject).to eq(ordered_requirements.map { |name| public_send(name) })
end
end
end
end
context 'when requirements are disabled' do
before do
stub_licensed_features(requirements: false)
end
context 'when project is set' do
let(:params) { { project_id: project.id } }
it 'does not return any requirements' do
is_expected.to be_empty
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::RequirementsResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
context 'with a project' do
let_it_be(:project) { create(:project) }
let_it_be(:requirement1) { create(:requirement, project: project, state: :opened, created_at: 5.hours.ago) }
let_it_be(:requirement2) { create(:requirement, project: project, state: :archived, created_at: 3.hours.ago) }
let_it_be(:requirement3) { create(:requirement, project: project, state: :archived, created_at: 4.hours.ago) }
before do
project.add_developer(current_user)
stub_licensed_features(requirements: true)
end
describe '#resolve' do
it 'finds all requirements' do
expect(resolve_requirements).to contain_exactly(requirement1, requirement2, requirement3)
end
it 'filters by state' do
expect(resolve_requirements(state: 'opened')).to contain_exactly(requirement1)
expect(resolve_requirements(state: 'archived')).to contain_exactly(requirement2, requirement3)
end
it 'filters by iid' do
expect(resolve_requirements(iid: requirement1.iid)).to contain_exactly(requirement1)
end
it 'filters by iids' do
expect(resolve_requirements(iids: [requirement1.iid, requirement3.iid])).to contain_exactly(requirement1, requirement3)
end
describe 'sorting' do
context 'when sorting by created_at' do
it 'sorts requirements ascending' do
expect(resolve_requirements(sort: 'created_asc')).to eq([requirement1, requirement3, requirement2])
end
it 'sorts requirements descending' do
expect(resolve_requirements(sort: 'created_desc')).to eq([requirement2, requirement3, requirement1])
end
end
end
it 'finds only the requirements within the project we are looking at' do
another_project = create(:project, :public)
create(:requirement, project: another_project, iid: requirement1.iid)
expect(resolve_requirements).to contain_exactly(requirement1, requirement2, requirement3)
end
end
context 'when `requirements_management` flag is disabled' do
before do
stub_feature_flags(requirements_management: false)
end
it 'returns an empty list' do
expect(resolve_requirements).to be_empty
end
end
end
def resolve_requirements(args = {}, context = { current_user: current_user })
resolve(described_class, obj: project, args: args, ctx: context)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'getting a requirement list for a project' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let_it_be(:requirement) { create(:requirement, project: project) }
let(:requirements_data) { graphql_data['project']['requirements']['edges'] }
let(:fields) do
<<~QUERY
edges {
node {
#{all_graphql_fields_for('requirements'.classify)}
}
}
QUERY
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('requirements', {}, fields)
)
end
context 'when user has access to the project' do
before do
stub_licensed_features(requirements: true)
project.add_developer(current_user)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'returns requirements successfully' do
post_graphql(query, current_user: current_user)
expect(graphql_errors).to be_nil
expect(requirements_data[0]['node']['id']).to eq requirement.to_global_id.to_s
end
context 'when limiting the number of results' do
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
"requirements(first: 1) { #{fields} }"
)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
end
describe 'sorting and pagination' do
let(:start_cursor) { graphql_data['project']['requirements']['pageInfo']['startCursor'] }
let(:end_cursor) { graphql_data['project']['requirements']['pageInfo']['endCursor'] }
def grab_iids(data = requirements_data)
data.map do |requirement_hash|
requirement_hash.dig('node', 'iid').to_i
end
end
context 'when sorting by created_at' do
let_it_be(:sort_project) { create(:project, :public) }
let_it_be(:requirement1) { create(:requirement, project: sort_project, created_at: 3.days.from_now) }
let_it_be(:requirement2) { create(:requirement, project: sort_project, created_at: 4.days.from_now) }
let_it_be(:requirement3) { create(:requirement, project: sort_project, created_at: 2.days.ago) }
let_it_be(:requirement4) { create(:requirement, project: sort_project, created_at: 5.days.ago) }
let_it_be(:requirement5) { create(:requirement, project: sort_project, created_at: 1.day.ago) }
let(:params) { 'sort: created_asc' }
def query(requirement_params = params)
graphql_query_for(
'project',
{ 'fullPath' => sort_project.full_path },
<<~REQUIREMENTS
requirements(#{requirement_params}) {
pageInfo {
endCursor
}
edges {
node {
iid
createdAt
}
}
}
REQUIREMENTS
)
end
def post_query_with_after_cursor(sort_by)
cursored_query = query("sort: #{sort_by}, after: \"#{end_cursor}\"")
post_graphql(cursored_query, current_user: current_user)
JSON.parse(response.body)['data']['project']['requirements']['edges']
end
before do
post_graphql(query, current_user: current_user)
end
it_behaves_like 'a working graphql query'
context 'when ascending' do
it 'sorts requirements' do
expect(grab_iids).to eq [requirement4.iid, requirement3.iid, requirement5.iid, requirement1.iid, requirement2.iid]
end
context 'when paginating' do
let(:params) { 'sort: created_asc, first: 2' }
it 'sorts requirements' do
expect(grab_iids).to eq [requirement4.iid, requirement3.iid]
response_data = post_query_with_after_cursor('created_asc')
expect(grab_iids(response_data)).to eq [requirement5.iid, requirement1.iid, requirement2.iid]
end
end
end
context 'when descending' do
let(:params) { 'sort: created_desc' }
it 'sorts requirements' do
expect(grab_iids).to eq [requirement2.iid, requirement1.iid, requirement5.iid, requirement3.iid, requirement4.iid]
end
context 'when paginating' do
let(:params) { 'sort: created_desc, first: 2' }
it 'sorts requirements' do
expect(grab_iids).to eq [requirement2.iid, requirement1.iid]
response_data = post_query_with_after_cursor('created_desc')
expect(grab_iids(response_data)).to eq [requirement5.iid, requirement3.iid, requirement4.iid]
end
end
end
end
end
end
context 'when the user does not have access to the requirement' do
before do
stub_licensed_features(requirements: true)
end
it 'returns nil' do
post_graphql(query)
expect(graphql_data['project']).to be_nil
end
end
context 'when requirements feature is not available' do
before do
stub_licensed_features(requirements: false)
project.add_developer(current_user)
end
it 'returns nil' do
post_graphql(query)
expect(graphql_data['project']).to be_nil
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