Commit 04b04658 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Add pipeline lists to GraphQL

This adds Keyset pagination to GraphQL lists. PoC for that is
pipelines on merge requests and projects.

When paginating a list, the base-64 encoded id of the ordering
field (in most cases the primary key) can be passed in the `before` or
`after` GraphQL argument.
parent cd578941
...@@ -13,7 +13,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -13,7 +13,7 @@ class Projects::PipelinesController < Projects::ApplicationController
def index def index
@scope = params[:scope] @scope = params[:scope]
@pipelines = PipelinesFinder @pipelines = PipelinesFinder
.new(project, scope: @scope) .new(project, current_user, scope: @scope)
.execute .execute
.page(params[:page]) .page(params[:page])
.per(30) .per(30)
...@@ -178,7 +178,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -178,7 +178,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end end
def limited_pipelines_count(project, scope = nil) def limited_pipelines_count(project, scope = nil)
finder = PipelinesFinder.new(project, scope: scope) finder = PipelinesFinder.new(project, current_user, scope: scope)
view_context.limited_counter_with_delimiter(finder.execute) view_context.limited_counter_with_delimiter(finder.execute)
end end
......
class PipelinesFinder class PipelinesFinder
attr_reader :project, :pipelines, :params attr_reader :project, :pipelines, :params, :current_user
ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze
def initialize(project, params = {}) def initialize(project, current_user, params = {})
@project = project @project = project
@current_user = current_user
@pipelines = project.pipelines @pipelines = project.pipelines
@params = params @params = params
end end
def execute def execute
unless Ability.allowed?(current_user, :read_pipeline, project)
return Ci::Pipeline.none
end
items = pipelines items = pipelines
items = by_scope(items) items = by_scope(items)
items = by_status(items) items = by_status(items)
......
...@@ -2,7 +2,10 @@ class GitlabSchema < GraphQL::Schema ...@@ -2,7 +2,10 @@ class GitlabSchema < GraphQL::Schema
use BatchLoader::GraphQL use BatchLoader::GraphQL
use Gitlab::Graphql::Authorize use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Present use Gitlab::Graphql::Present
use Gitlab::Graphql::Connections
query(Types::QueryType) query(Types::QueryType)
default_max_page_size 100
# mutation(Types::MutationType) # mutation(Types::MutationType)
end end
module ResolvesPipelines
extend ActiveSupport::Concern
included do
type [Types::Ci::PipelineType], null: false
argument :status,
Types::Ci::PipelineStatusEnum,
required: false,
description: "Filter pipelines by their status"
argument :ref,
GraphQL::STRING_TYPE,
required: false,
description: "Filter pipelines by the ref they are run for"
argument :sha,
GraphQL::STRING_TYPE,
required: false,
description: "Filter pipelines by the sha of the commit they are run for"
end
def resolve_pipelines(project, params = {})
PipelinesFinder.new(project, context[:current_user], params).execute
end
end
module Resolvers
class MergeRequestPipelinesResolver < BaseResolver
include ::ResolvesPipelines
alias_method :merge_request, :object
def resolve(**args)
resolve_pipelines(project, args)
.merge(merge_request.all_pipelines)
end
def project
merge_request.source_project
end
end
end
module Resolvers
class ProjectPipelinesResolver < BaseResolver
include ResolvesPipelines
alias_method :project, :object
def resolve(**args)
resolve_pipelines(project, args)
end
end
end
module Types
module Ci
class PipelineStatusEnum < BaseEnum
::Ci::Pipeline.all_state_names.each do |state_symbol|
value state_symbol.to_s.upcase, value: state_symbol.to_s
end
end
end
end
module Types
module Ci
class PipelineType < BaseObject
expose_permissions Types::PermissionTypes::Ci::Pipeline
graphql_name 'Pipeline'
field :id, GraphQL::ID_TYPE, null: false
field :iid, GraphQL::ID_TYPE, null: false
field :sha, GraphQL::STRING_TYPE, null: false
field :before_sha, GraphQL::STRING_TYPE, null: true
field :status, PipelineStatusEnum, null: false
field :duration,
GraphQL::INT_TYPE,
null: true,
description: "Duration of the pipeline in seconds"
field :coverage,
GraphQL::FLOAT_TYPE,
null: true,
description: "Coverage percentage"
field :created_at, Types::TimeType, null: false
field :updated_at, Types::TimeType, null: false
field :started_at, Types::TimeType, null: true
field :finished_at, Types::TimeType, null: true
field :committed_at, Types::TimeType, null: true
# TODO: Add triggering user as a type
end
end
end
...@@ -45,5 +45,11 @@ module Types ...@@ -45,5 +45,11 @@ module Types
field :upvotes, GraphQL::INT_TYPE, null: false field :upvotes, GraphQL::INT_TYPE, null: false
field :downvotes, GraphQL::INT_TYPE, null: false field :downvotes, GraphQL::INT_TYPE, null: false
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false
field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline do
authorize :read_pipeline
end
field :pipelines, Types::Ci::PipelineType.connection_type,
resolver: Resolvers::MergeRequestPipelinesResolver
end end
end end
module Types
module PermissionTypes
module Ci
class Pipeline < BasePermissionType
graphql_name 'PipelinePermissions'
abilities :update_pipeline, :admin_pipeline, :destroy_pipeline
end
end
end
end
...@@ -70,5 +70,10 @@ module Types ...@@ -70,5 +70,10 @@ module Types
resolver: Resolvers::MergeRequestResolver do resolver: Resolvers::MergeRequestResolver do
authorize :read_merge_request authorize :read_merge_request
end end
field :pipelines,
Types::Ci::PipelineType.connection_type,
null: false,
resolver: Resolvers::ProjectPipelinesResolver
end end
end end
---
title: Add pipeline lists to GraphQL
merge_request: 20249
author:
type: added
...@@ -45,7 +45,8 @@ module Gitlab ...@@ -45,7 +45,8 @@ module Gitlab
#{config.root}/app/workers/concerns #{config.root}/app/workers/concerns
#{config.root}/app/services/concerns #{config.root}/app/services/concerns
#{config.root}/app/serializers/concerns #{config.root}/app/serializers/concerns
#{config.root}/app/finders/concerns]) #{config.root}/app/finders/concerns
#{config.root}/app/graphql/resolvers/concerns])
config.generators.templates.push("#{config.root}/generator_templates") config.generators.templates.push("#{config.root}/generator_templates")
......
...@@ -54,6 +54,94 @@ a new presenter specifically for GraphQL. ...@@ -54,6 +54,94 @@ a new presenter specifically for GraphQL.
The presenter is initialized using the object resolved by a field, and The presenter is initialized using the object resolved by a field, and
the context. the context.
### Connection Types
GraphQL uses [cursor based
pagination](https://graphql.org/learn/pagination/#pagination-and-edges)
to expose collections of items. This provides the clients with a lot
of flexibility while also allowing the backend to use different
pagination models.
To expose a collection of resources we can use a connection type. This wraps the array with default pagination fields. For example a query for project-pipelines could look like this:
```
query($project_path: ID!) {
project(fullPath: $project_path) {
pipelines(first: 2) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
cursor
node {
id
status
}
}
}
}
}
```
This would return the first 2 pipelines of a project and related
pagination info., ordered by descending ID. The returned data would
look like this:
```json
{
"data": {
"project": {
"pipelines": {
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false
},
"edges": [
{
"cursor": "Nzc=",
"node": {
"id": "77",
"status": "FAILED"
}
},
{
"cursor": "Njc=",
"node": {
"id": "67",
"status": "FAILED"
}
}
]
}
}
}
}
```
To get the next page, the cursor of the last known element could be
passed:
```
query($project_path: ID!) {
project(fullPath: $project_path) {
pipelines(first: 2, after: "Njc=") {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
cursor
node {
id
status
}
}
}
}
}
```
### Exposing permissions for a type ### Exposing permissions for a type
To expose permissions the current user has on a resource, you can call To expose permissions the current user has on a resource, you can call
......
...@@ -31,7 +31,7 @@ module API ...@@ -31,7 +31,7 @@ module API
get ':id/pipelines' do get ':id/pipelines' do
authorize! :read_pipeline, user_project authorize! :read_pipeline, user_project
pipelines = PipelinesFinder.new(user_project, params).execute pipelines = PipelinesFinder.new(user_project, current_user, params).execute
present paginate(pipelines), with: Entities::PipelineBasic present paginate(pipelines), with: Entities::PipelineBasic
end end
......
module Gitlab
module Graphql
module Connections
def self.use(_schema)
GraphQL::Relay::BaseConnection.register_connection_implementation(
ActiveRecord::Relation,
Gitlab::Graphql::Connections::KeysetConnection
)
end
end
end
end
module Gitlab
module Graphql
module Connections
class KeysetConnection < GraphQL::Relay::BaseConnection
def cursor_from_node(node)
encode(node[order_field].to_s)
end
def sliced_nodes
@sliced_nodes ||=
begin
sliced = nodes
sliced = sliced.where(before_slice) if before.present?
sliced = sliced.where(after_slice) if after.present?
sliced
end
end
def paged_nodes
if first && last
raise Gitlab::Graphql::Errors::ArgumentError.new("Can only provide either `first` or `last`, not both")
end
if last
sliced_nodes.last(limit_value)
else
sliced_nodes.limit(limit_value)
end
end
private
def before_slice
if sort_direction == :asc
table[order_field].lt(decode(before))
else
table[order_field].gt(decode(before))
end
end
def after_slice
if sort_direction == :asc
table[order_field].gt(decode(after))
else
table[order_field].lt(decode(after))
end
end
def limit_value
@limit_value ||= [first, last, max_page_size].compact.min
end
def table
nodes.arel_table
end
def order_info
@order_info ||= nodes.order_values.first
end
def order_field
@order_field ||= order_info&.expr&.name || nodes.primary_key
end
def sort_direction
@order_direction ||= order_info&.direction || :desc
end
end
end
end
end
module Gitlab
module Graphql
module Errors
BaseError = Class.new(GraphQL::ExecutionError)
ArgumentError = Class.new(BaseError)
end
end
end
...@@ -3,6 +3,8 @@ module Gitlab ...@@ -3,6 +3,8 @@ module Gitlab
module Present module Present
class Instrumentation class Instrumentation
def instrument(type, field) def instrument(type, field)
return field unless field.metadata[:type_class]
presented_in = field.metadata[:type_class].owner presented_in = field.metadata[:type_class].owner
return field unless presented_in.respond_to?(:presenter_class) return field unless presented_in.respond_to?(:presenter_class)
return field unless presented_in.presenter_class return field unless presented_in.presenter_class
......
...@@ -4,7 +4,7 @@ describe Projects::PipelinesController do ...@@ -4,7 +4,7 @@ describe Projects::PipelinesController do
include ApiHelpers include ApiHelpers
set(:user) { create(:user) } set(:user) { create(:user) }
set(:project) { create(:project, :public, :repository) } let(:project) { create(:project, :public, :repository) }
let(:feature) { ProjectFeature::DISABLED } let(:feature) { ProjectFeature::DISABLED }
before do before do
...@@ -91,6 +91,24 @@ describe Projects::PipelinesController do ...@@ -91,6 +91,24 @@ describe Projects::PipelinesController do
end end
end end
context 'when the project is private' do
let(:project) { create(:project, :private, :repository) }
it 'returns `not_found` when the user does not have access' do
sign_in(create(:user))
get_pipelines_index_json
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns the pipelines when the user has access' do
get_pipelines_index_json
expect(json_response['pipelines'].size).to eq(5)
end
end
def get_pipelines_index_json def get_pipelines_index_json
get :index, namespace_id: project.namespace, get :index, namespace_id: project.namespace,
project_id: project, project_id: project,
......
require 'spec_helper' require 'spec_helper'
describe PipelinesFinder do describe PipelinesFinder do
let(:project) { create(:project, :repository) } let(:project) { create(:project, :public, :repository) }
let(:current_user) { nil }
subject { described_class.new(project, params).execute } let(:params) { {} }
subject { described_class.new(project, current_user, params).execute }
describe "#execute" do describe "#execute" do
context 'when params is empty' do context 'when params is empty' do
...@@ -223,5 +224,27 @@ describe PipelinesFinder do ...@@ -223,5 +224,27 @@ describe PipelinesFinder do
end end
end end
end end
context 'when the project has limited access to piplines' do
let(:project) { create(:project, :private, :repository) }
let(:current_user) { create(:user) }
let!(:pipelines) { create_list(:ci_pipeline, 2, project: project) }
context 'when the user has access' do
before do
project.add_developer(current_user)
end
it 'is expected to return pipelines' do
is_expected.to contain_exactly(*pipelines)
end
end
context 'the user is not allowed to read pipelines' do
it 'returns empty' do
is_expected.to be_empty
end
end
end
end end
end end
...@@ -27,6 +27,12 @@ describe GitlabSchema do ...@@ -27,6 +27,12 @@ describe GitlabSchema do
expect(described_class.query).to eq(::Types::QueryType.to_graphql) expect(described_class.query).to eq(::Types::QueryType.to_graphql)
end end
it 'paginates active record relations using `Gitlab::Graphql::Connections::KeysetConnection`' do
connection = GraphQL::Relay::BaseConnection::CONNECTION_IMPLEMENTATIONS[ActiveRecord::Relation.name]
expect(connection).to eq(Gitlab::Graphql::Connections::KeysetConnection)
end
def field_instrumenters def field_instrumenters
described_class.instrumenters[:field] described_class.instrumenters[:field]
end end
......
require 'spec_helper'
describe ResolvesPipelines do
include GraphqlHelpers
subject(:resolver) do
Class.new(Resolvers::BaseResolver) do
include ResolvesPipelines
def resolve(**args)
resolve_pipelines(object, args)
end
end
end
let(:current_user) { create(:user) }
set(:project) { create(:project, :private) }
set(:pipeline) { create(:ci_pipeline, project: project) }
set(:failed_pipeline) { create(:ci_pipeline, :failed, project: project) }
set(:ref_pipeline) { create(:ci_pipeline, project: project, ref: 'awesome-feature') }
set(:sha_pipeline) { create(:ci_pipeline, project: project, sha: 'deadbeef') }
before do
project.add_developer(current_user)
end
it { is_expected.to have_graphql_arguments(:status, :ref, :sha) }
it 'finds all pipelines' do
expect(resolve_pipelines).to contain_exactly(pipeline, failed_pipeline, ref_pipeline, sha_pipeline)
end
it 'allows filtering by status' do
expect(resolve_pipelines(status: 'failed')).to contain_exactly(failed_pipeline)
end
it 'allows filtering by ref' do
expect(resolve_pipelines(ref: 'awesome-feature')).to contain_exactly(ref_pipeline)
end
it 'allows filtering by sha' do
expect(resolve_pipelines(sha: 'deadbeef')).to contain_exactly(sha_pipeline)
end
it 'does not return any pipelines if the user does not have access' do
expect(resolve_pipelines({}, {})).to be_empty
end
def resolve_pipelines(args = {}, context = { current_user: current_user })
resolve(resolver, obj: project, args: args, ctx: context)
end
end
require 'spec_helper'
describe Resolvers::MergeRequestPipelinesResolver do
include GraphqlHelpers
set(:merge_request) { create(:merge_request) }
set(:pipeline) do
create(
:ci_pipeline,
project: merge_request.source_project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha
)
end
set(:other_project_pipeline) { create(:ci_pipeline, project: merge_request.source_project) }
set(:other_pipeline) { create(:ci_pipeline) }
let(:current_user) { create(:user) }
before do
merge_request.project.add_developer(current_user)
end
def resolve_pipelines
resolve(described_class, obj: merge_request, ctx: { current_user: current_user })
end
it 'resolves only MRs for the passed merge request' do
expect(resolve_pipelines).to contain_exactly(pipeline)
end
end
require 'spec_helper'
describe Resolvers::ProjectPipelinesResolver do
include GraphqlHelpers
set(:project) { create(:project) }
set(:pipeline) { create(:ci_pipeline, project: project) }
set(:other_pipeline) { create(:ci_pipeline) }
let(:current_user) { create(:user) }
before do
project.add_developer(current_user)
end
def resolve_pipelines
resolve(described_class, obj: project, ctx: { current_user: current_user })
end
it 'resolves only MRs for the passed merge request' do
expect(resolve_pipelines).to contain_exactly(pipeline)
end
end
require 'spec_helper'
describe Types::Ci::PipelineType do
it { expect(described_class.graphql_name).to eq('Pipeline') }
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Ci::Pipeline) }
end
require 'spec_helper' require 'spec_helper'
describe Types::MergeRequestType do describe GitlabSchema.types['MergeRequest'] do
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) } it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
describe 'head pipeline' do
it 'has a head pipeline field' do
expect(described_class).to have_graphql_field(:head_pipeline)
end
it 'authorizes the field' do
expect(described_class.fields['headPipeline'])
.to require_graphql_authorizations(:read_pipeline)
end
end
end end
...@@ -13,4 +13,6 @@ describe GitlabSchema.types['Project'] do ...@@ -13,4 +13,6 @@ describe GitlabSchema.types['Project'] do
.to require_graphql_authorizations(:read_merge_request) .to require_graphql_authorizations(:read_merge_request)
end end
end end
it { is_expected.to have_graphql_field(:pipelines) }
end end
require 'spec_helper'
describe Gitlab::Graphql::Connections::KeysetConnection do
let(:nodes) { Project.all.order(id: :asc) }
let(:arguments) { {} }
subject(:connection) do
described_class.new(nodes, arguments, max_page_size: 3)
end
def encoded_property(value)
Base64.strict_encode64(value.to_s)
end
describe '#cursor_from_nodes' do
let(:project) { create(:project) }
it 'returns an encoded ID' do
expect(connection.cursor_from_node(project))
.to eq(encoded_property(project.id))
end
context 'when an order was specified' do
let(:nodes) { Project.order(:updated_at) }
it 'returns the encoded value of the order' do
expect(connection.cursor_from_node(project))
.to eq(encoded_property(project.updated_at))
end
end
end
describe '#sliced_nodes' do
let(:projects) { create_list(:project, 4) }
context 'when before is passed' do
let(:arguments) { { before: encoded_property(projects[1].id) } }
it 'only returns the project before the selected one' do
expect(subject.sliced_nodes).to contain_exactly(projects.first)
end
context 'when the sort order is descending' do
let(:nodes) { Project.all.order(id: :desc) }
it 'returns the correct nodes' do
expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1])
end
end
end
context 'when after is passed' do
let(:arguments) { { after: encoded_property(projects[1].id) } }
it 'only returns the project before the selected one' do
expect(subject.sliced_nodes).to contain_exactly(*projects[2..-1])
end
context 'when the sort order is descending' do
let(:nodes) { Project.all.order(id: :desc) }
it 'returns the correct nodes' do
expect(subject.sliced_nodes).to contain_exactly(projects.first)
end
end
end
context 'when both before and after are passed' do
let(:arguments) do
{
after: encoded_property(projects[1].id),
before: encoded_property(projects[3].id)
}
end
it 'returns the expected set' do
expect(subject.sliced_nodes).to contain_exactly(projects[2])
end
end
end
describe '#paged_nodes' do
let!(:projects) { create_list(:project, 5) }
it 'returns the collection limited to max page size' do
expect(subject.paged_nodes.size).to eq(3)
end
context 'when `first` is passed' do
let(:arguments) { { first: 2 } }
it 'returns only the first elements' do
expect(subject.paged_nodes).to contain_exactly(projects.first, projects.second)
end
end
context 'when `last` is passed' do
let(:arguments) { { last: 2 } }
it 'returns only the last elements' do
expect(subject.paged_nodes).to contain_exactly(projects[3], projects[4])
end
end
context 'when both are passed' do
let(:arguments) { { first: 2, last: 2 } }
it 'raises an error' do
expect { subject.paged_nodes }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
end
end
end
...@@ -67,4 +67,28 @@ describe 'getting merge request information nested in a project' do ...@@ -67,4 +67,28 @@ describe 'getting merge request information nested in a project' do
expect(merge_request_graphql_data).to be_nil expect(merge_request_graphql_data).to be_nil
end end
end end
context 'when there are pipelines' do
before do
pipeline = create(
:ci_pipeline,
project: merge_request.source_project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha
)
merge_request.update!(head_pipeline: pipeline)
end
it 'has a head pipeline' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data['headPipeline']).to be_present
end
it 'has pipeline connections' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data['pipelines']['edges'].size).to eq(1)
end
end
end end
...@@ -26,6 +26,18 @@ describe 'getting project information' do ...@@ -26,6 +26,18 @@ describe 'getting project information' do
post_graphql(query, current_user: current_user) post_graphql(query, current_user: current_user)
end end
end end
context 'when there are pipelines present' do
before do
create(:ci_pipeline, project: project)
end
it 'is included in the pipelines connection' do
post_graphql(query, current_user: current_user)
expect(graphql_data['project']['pipelines']['edges'].size).to eq(1)
end
end
end end
context 'when the user does not have access to the project' do context 'when the user does not have access to the project' do
......
...@@ -57,12 +57,12 @@ module GraphqlHelpers ...@@ -57,12 +57,12 @@ module GraphqlHelpers
type.fields.map do |name, field| type.fields.map do |name, field|
# We can't guess arguments, so skip fields that require them # We can't guess arguments, so skip fields that require them
next if field.arguments.any? next if required_arguments?(field)
if scalar?(field) if nested_fields?(field)
name
else
"#{name} { #{all_graphql_fields_for(field_type(field))} }" "#{name} { #{all_graphql_fields_for(field_type(field))} }"
else
name
end end
end.compact.join("\n") end.compact.join("\n")
end end
...@@ -85,10 +85,22 @@ module GraphqlHelpers ...@@ -85,10 +85,22 @@ module GraphqlHelpers
json_response['data'] json_response['data']
end end
def nested_fields?(field)
!scalar?(field) && !enum?(field)
end
def scalar?(field) def scalar?(field)
field_type(field).kind.scalar? field_type(field).kind.scalar?
end end
def enum?(field)
field_type(field).kind.enum?
end
def required_arguments?(field)
field.arguments.values.any? { |argument| argument.type.non_null? }
end
def field_type(field) def field_type(field)
if field.type.respond_to?(:of_type) if field.type.respond_to?(:of_type)
field.type.of_type field.type.of_type
......
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