Commit 2c3ae4c3 authored by Patrick Bajao's avatar Patrick Bajao

Merge branch 'ajk-graphql-helpers-resolve-field' into 'master'

GraphQL Helper: Make `#resolve` and `#resolve_field` fully faithful

See merge request gitlab-org/gitlab!55307
parents 4b06a87e 2ad30bac
......@@ -2,17 +2,22 @@
require 'spec_helper'
RSpec.describe 'Gitlab::Graphql::Authorization' do
RSpec.describe 'Gitlab::Graphql::Authorize' do
include GraphqlHelpers
include Graphql::ResolverFactories
let_it_be(:user) { create(:user) }
let(:permission_single) { :foo }
let(:permission_collection) { [:foo, :bar] }
let(:test_object) { double(name: 'My name') }
let(:query_string) { '{ item { name } }' }
let(:result) { execute_query(query_type)['data'] }
let(:result) do
schema = empty_schema
schema.use(Gitlab::Graphql::Authorize)
execute_query(query_type, schema: schema)
end
subject { result['item'] }
subject { result.dig('data', 'item') }
shared_examples 'authorization with a single permission' do
it 'returns the protected field when user has permission' do
......@@ -55,7 +60,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
describe 'with a single permission' do
let(:query_type) do
query_factory do |query|
query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_single
query.field :item, type, null: true, resolver: new_resolver(test_object), authorize: permission_single
end
end
......@@ -66,7 +71,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
let(:query_type) do
permissions = permission_collection
query_factory do |qt|
qt.field :item, type, null: true, resolver: simple_resolver(test_object) do
qt.field :item, type, null: true, resolver: new_resolver(test_object) do
authorize permissions
end
end
......@@ -79,7 +84,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
describe 'Field authorizations when field is a built in type' do
let(:query_type) do
query_factory do |query|
query.field :item, type, null: true, resolver: simple_resolver(test_object)
query.field :item, type, null: true, resolver: new_resolver(test_object)
end
end
......@@ -132,7 +137,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
describe 'Type authorizations' do
let(:query_type) do
query_factory do |query|
query.field :item, type, null: true, resolver: simple_resolver(test_object)
query.field :item, type, null: true, resolver: new_resolver(test_object)
end
end
......@@ -169,7 +174,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
let(:query_type) do
query_factory do |query|
query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_2
query.field :item, type, null: true, resolver: new_resolver(test_object), authorize: permission_2
end
end
......@@ -188,11 +193,11 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
let(:query_type) do
query_factory do |query|
query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object])
query.field :item, type.connection_type, null: true, resolver: new_resolver([test_object, second_test_object])
end
end
subject { result.dig('item', 'edges') }
subject { result.dig('data', 'item', 'edges') }
it 'returns only the elements visible to the user' do
permit(permission_single)
......@@ -208,7 +213,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
describe 'limiting connections with multiple objects' do
let(:query_type) do
query_factory do |query|
query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object])
query.field :item, type.connection_type, null: true, resolver: new_resolver([test_object, second_test_object])
end
end
......@@ -232,11 +237,11 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
let(:query_type) do
query_factory do |query|
query.field :item, [type], null: true, resolver: simple_resolver([test_object])
query.field :item, [type], null: true, resolver: new_resolver([test_object])
end
end
subject { result['item'].first }
subject { result.dig('data', 'item', 0) }
include_examples 'authorization with a single permission'
end
......@@ -260,13 +265,13 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
type_factory do |type|
type.graphql_name 'FakeProjectType'
type.field :test_issues, issue_type.connection_type, null: false,
resolver: simple_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc))
resolver: new_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc))
end
end
let(:query_type) do
query_factory do |query|
query.field :test_project, project_type, null: false, resolver: simple_resolver(visible_project)
query.field :test_project, project_type, null: false, resolver: new_resolver(visible_project)
end
end
......@@ -281,7 +286,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do
end
it 'renders the issues the user has access to' do
issue_edges = result['testProject']['testIssues']['edges']
issue_edges = result.dig('data', 'testProject', 'testIssues', 'edges')
issue_ids = issue_edges.map { |issue_edge| issue_edge['node']&.fetch('id') }
expect(issue_edges.size).to eq(visible_issues.size)
......
......@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Graphql Field feature flags' do
include GraphqlHelpers
include Graphql::ResolverFactories
let_it_be(:user) { create(:user) }
......@@ -23,7 +24,7 @@ RSpec.describe 'Graphql Field feature flags' do
let(:query_type) do
query_factory do |query|
query.field :item, type, null: true, feature_flag: feature_flag, resolver: simple_resolver(test_object)
query.field :item, type, null: true, feature_flag: feature_flag, resolver: new_resolver(test_object)
end
end
......
......@@ -21,9 +21,9 @@ RSpec.describe Mutations::ReleaseAssetLinks::Create do
let(:args) do
{
project_path: project_path,
tag: tag,
tag_name: tag,
name: name,
filepath: filepath,
direct_asset_path: filepath,
url: url
}
end
......@@ -44,9 +44,9 @@ RSpec.describe Mutations::ReleaseAssetLinks::Create do
expect(release.links.length).to be(1)
expect(last_release_link.name).to eq(args[:name])
expect(last_release_link.url).to eq(args[:url])
expect(last_release_link.filepath).to eq(args[:filepath])
expect(last_release_link.name).to eq(name)
expect(last_release_link.url).to eq(url)
expect(last_release_link.filepath).to eq(filepath)
end
end
......
......@@ -9,7 +9,6 @@ RSpec.describe ::CachingArrayResolver do
let_it_be(:admins) { create_list(:user, 4, admin: true) }
let(:query_context) { { current_user: admins.first } }
let(:max_page_size) { 10 }
let(:field) { double('Field', max_page_size: max_page_size) }
let(:schema) do
Class.new(GitlabSchema) do
default_max_page_size 3
......@@ -210,6 +209,6 @@ RSpec.describe ::CachingArrayResolver do
args = { is_admin: admin }
opts = resolver.field_options
allow(resolver).to receive(:field_options).and_return(opts.merge(max_page_size: max_page_size))
resolve(resolver, args: args, ctx: query_context, schema: schema, field: field)
resolve(resolver, args: args, ctx: query_context, schema: schema)
end
end
......@@ -19,7 +19,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
end
describe '#resolve' do
context 'insufficient user permission' do
context 'with insufficient user permission' do
let(:user) { create(:user) }
it 'returns nil' do
......@@ -29,7 +29,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
end
end
context 'user with permission' do
context 'with sufficient permission' do
before do
project.add_developer(current_user)
......@@ -93,7 +93,7 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
end
it 'returns an externally paginated array' do
expect(resolve_errors).to be_a Gitlab::Graphql::ExternallyPaginatedArray
expect(resolve_errors).to be_a Gitlab::Graphql::Pagination::ExternallyPaginatedArrayConnection
end
end
end
......
......@@ -42,7 +42,7 @@ RSpec.describe Resolvers::GroupLabelsResolver do
context 'without parent' do
it 'returns no labels' do
expect(resolve_labels(nil)).to eq(Label.none)
expect(resolve_labels(nil)).to be_empty
end
end
......
......@@ -264,7 +264,7 @@ RSpec.describe Resolvers::IssuesResolver do
end
it 'finds a specific issue with iid', :request_store do
result = batch_sync(max_queries: 4) { resolve_issues(iid: issue1.iid) }
result = batch_sync(max_queries: 4) { resolve_issues(iid: issue1.iid).to_a }
expect(result).to contain_exactly(issue1)
end
......@@ -281,7 +281,7 @@ RSpec.describe Resolvers::IssuesResolver do
it 'finds a specific issue with iids', :request_store do
result = batch_sync(max_queries: 4) do
resolve_issues(iids: [issue1.iid])
resolve_issues(iids: [issue1.iid]).to_a
end
expect(result).to contain_exactly(issue1)
......@@ -290,7 +290,7 @@ RSpec.describe Resolvers::IssuesResolver do
it 'finds multiple issues with iids' do
create(:issue, project: project, author: current_user)
expect(batch_sync { resolve_issues(iids: [issue1.iid, issue2.iid]) })
expect(batch_sync { resolve_issues(iids: [issue1.iid, issue2.iid]).to_a })
.to contain_exactly(issue1, issue2)
end
......@@ -302,7 +302,7 @@ RSpec.describe Resolvers::IssuesResolver do
create(:issue, project: another_project, iid: iid)
end
expect(batch_sync { resolve_issues(iids: iids) }).to contain_exactly(issue1, issue2)
expect(batch_sync { resolve_issues(iids: iids).to_a }).to contain_exactly(issue1, issue2)
end
end
end
......
......@@ -42,50 +42,36 @@ RSpec.describe Resolvers::LabelsResolver do
context 'without parent' do
it 'returns no labels' do
expect(resolve_labels(nil)).to eq(Label.none)
expect(resolve_labels(nil)).to be_empty
end
end
context 'at project level' do
context 'with a parent project' do
before_all do
group.add_developer(current_user)
end
# because :include_ancestor_groups, :include_descendant_groups, :only_group_labels default to false
# the `nil` value would be equivalent to passing in `false` so just check for `nil` option
where(:include_ancestor_groups, :include_descendant_groups, :only_group_labels, :search_term, :test) do
nil | nil | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2) }
nil | nil | true | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2) }
nil | true | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) }
nil | true | true | nil | -> { expect(subject).to contain_exactly(label1, label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) }
true | nil | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2) }
true | nil | true | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2) }
true | true | nil | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) }
true | true | true | nil | -> { expect(subject).to contain_exactly(label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2, sub_subgroup_label1, sub_subgroup_label2) }
nil | nil | nil | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2) }
nil | nil | true | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2) }
nil | true | nil | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2, sub_subgroup_label2) }
nil | true | true | 'new' | -> { expect(subject).to contain_exactly(label2, subgroup_label2, sub_subgroup_label2) }
true | nil | nil | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2) }
true | nil | true | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2) }
true | true | nil | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2, sub_subgroup_label2) }
true | true | true | 'new' | -> { expect(subject).to contain_exactly(label2, group_label2, subgroup_label2, sub_subgroup_label2) }
# the expected result is wrapped in a lambda to get around the phase restrictions of RSpec::Parameterized
where(:include_ancestor_groups, :search_term, :expected_labels) do
nil | nil | -> { [label1, label2, subgroup_label1, subgroup_label2] }
false | nil | -> { [label1, label2, subgroup_label1, subgroup_label2] }
true | nil | -> { [label1, label2, group_label1, group_label2, subgroup_label1, subgroup_label2] }
nil | 'new' | -> { [label2, subgroup_label2] }
false | 'new' | -> { [label2, subgroup_label2] }
true | 'new' | -> { [label2, group_label2, subgroup_label2] }
end
with_them do
let(:params) do
{
include_ancestor_groups: include_ancestor_groups,
include_descendant_groups: include_descendant_groups,
only_group_labels: only_group_labels,
search_term: search_term
}
end
subject { resolve_labels(project, params) }
it { self.instance_exec(&test) }
specify { expect(subject).to match_array(instance_exec(&expected_labels)) }
end
end
end
......
......@@ -69,7 +69,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
it 'batch-resolves by target project full path and IIDS', :request_store do
result = batch_sync(max_queries: queries_per_project) do
resolve_mr(project, iids: [iid_1, iid_2])
resolve_mr(project, iids: [iid_1, iid_2]).to_a
end
expect(result).to contain_exactly(merge_request_1, merge_request_2)
......
......@@ -14,7 +14,7 @@ RSpec.describe Resolvers::ReleaseMilestonesResolver do
describe '#resolve' do
it "uses offset-pagination" do
expect(resolved).to be_a(::Gitlab::Graphql::Pagination::OffsetPaginatedRelation)
expect(resolved).to be_a(::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection)
end
it "includes the release's milestones in the returned OffsetActiveRecordRelationConnection" do
......
# frozen_string_literal: true
module Graphql
module ResolverFactories
def new_resolver(resolved_value = 'Resolved value', method: :resolve)
case method
when :resolve
simple_resolver(resolved_value)
when :find_object
find_object_resolver(resolved_value)
else
raise "Cannot build a resolver for #{method}"
end
end
private
def simple_resolver(resolved_value = 'Resolved value')
Class.new(Resolvers::BaseResolver) do
define_method :resolve do |**_args|
resolved_value
end
end
end
def find_object_resolver(resolved_value = 'Found object')
Class.new(Resolvers::BaseResolver) do
include ::Gitlab::Graphql::Authorize::AuthorizeResource
def resolve(**args)
authorized_find!(**args)
end
define_method :find_object do |**_args|
resolved_value
end
end
end
end
end
......@@ -16,32 +16,127 @@ module GraphqlHelpers
underscored_field_name.to_s.camelize(:lower)
end
# Run a loader's named resolver in a way that closely mimics the framework.
def self.deep_fieldnamerize(map)
map.to_h do |k, v|
[fieldnamerize(k), v.is_a?(Hash) ? deep_fieldnamerize(v) : v]
end
end
# Run this resolver exactly as it would be called in the framework. This
# includes all authorization hooks, all argument processing and all result
# wrapping.
# see: GraphqlHelpers#resolve_field
def resolve(
resolver_class, # [Class[<= BaseResolver]] The resolver at test.
obj: nil, # [Any] The BaseObject#object for the resolver (available as `#object` in the resolver).
args: {}, # [Hash] The arguments to the resolver (using client names).
ctx: {}, # [#to_h] The current context values.
schema: GitlabSchema, # [GraphQL::Schema] Schema to use during execution.
parent: :not_given, # A GraphQL query node to be passed as the `:parent` extra.
lookahead: :not_given # A GraphQL lookahead object to be passed as the `:lookahead` extra.
)
# All resolution goes through fields, so we need to create one here that
# uses our resolver. Thankfully, apart from the field name, resolvers
# contain all the configuration needed to define one.
field_options = resolver_class.field_options.merge(name: 'field_value')
field = ::Types::BaseField.new(**field_options)
# All mutations accept a single `:input` argument. Wrap arguments here.
# See the unwrapping below in GraphqlHelpers#resolve_field
args = { input: args } if resolver_class <= ::Mutations::BaseMutation && !args.key?(:input)
resolve_field(field, obj,
args: args,
ctx: ctx,
schema: schema,
object_type: resolver_parent,
extras: { parent: parent, lookahead: lookahead })
end
# Resolve the value of a field on an object.
#
# Use this method to test individual fields within type specs.
#
# First the `ready?` method is called. If it turns out that the resolver is not
# ready, then the early return is returned instead.
# e.g.
#
# Then the resolve method is called.
def resolve(resolver_class, args: {}, lookahead: :not_given, parent: :not_given, **resolver_args)
args = aliased_args(resolver_class, args)
args[:parent] = parent unless parent == :not_given
args[:lookahead] = lookahead unless lookahead == :not_given
resolver = resolver_instance(resolver_class, **resolver_args)
ready, early_return = sync_all { resolver.ready?(**args) }
# issue = create(:issue)
# user = issue.author
# project = issue.project
#
# resolve_field(:author, issue, current_user: user, object_type: ::Types::IssueType)
# resolve_field(:issue, project, args: { iid: issue.iid }, current_user: user, object_type: ::Types::ProjectType)
#
# The `object_type` defaults to the `described_class`, so when called from type specs,
# the above can be written as:
#
# # In project_type_spec.rb
# resolve_field(:author, issue, current_user: user)
#
# # In issue_type_spec.rb
# resolve_field(:issue, project, args: { iid: issue.iid }, current_user: user)
#
# NB: Arguments are passed from the client's perspective. If there is an argument
# `foo` aliased as `bar`, then we would pass `args: { bar: the_value }`, and
# types are checked before resolution.
def resolve_field(
field, # An instance of `BaseField`, or the name of a field on the current described_class
object, # The current object of the `BaseObject` this field 'belongs' to
args: {}, # Field arguments (keys will be fieldnamerized)
ctx: {}, # Context values (important ones are :current_user)
extras: {}, # Stub values for field extras (parent and lookahead)
current_user: :not_given, # The current user (specified explicitly, overrides ctx[:current_user])
schema: GitlabSchema, # A specific schema instance
object_type: described_class # The `BaseObject` type this field belongs to
)
field = to_base_field(field, object_type)
ctx[:current_user] = current_user unless current_user == :not_given
query = GraphQL::Query.new(schema, context: ctx.to_h)
extras[:lookahead] = negative_lookahead if extras[:lookahead] == :not_given && field.extras.include?(:lookahead)
query_ctx = query.context
mock_extras(query_ctx, **extras)
parent = object_type.authorized_new(object, query_ctx)
raise UnauthorizedObject unless parent
# TODO: This will need to change when we move to the interpreter:
# At that point, arguments will be a plain ruby hash rather than
# an Arguments object
# see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536
# https://gitlab.com/gitlab-org/gitlab/-/issues/210556
arguments = field.to_graphql.arguments_class.new(
GraphqlHelpers.deep_fieldnamerize(args),
context: query_ctx,
defaults_used: []
)
# we enable the request store so we can track gitaly calls.
::Gitlab::WithRequestStore.with_request_store do
# TODO: This will need to change when we move to the interpreter - at that
# point we will call `field#resolve`
return early_return unless ready
# Unwrap the arguments to mutations. This pairs with the wrapping in GraphqlHelpers#resolve
# If arguments are not wrapped first, then arguments processing will raise.
# If arguments are not unwrapped here, then the resolve method of the mutation will raise argument errors.
arguments = arguments.to_kwargs[:input] if field.resolver && field.resolver <= ::Mutations::BaseMutation
resolver.resolve(**args)
field.resolve_field(parent, arguments, query_ctx)
end
end
# TODO: Remove this method entirely when GraphqlHelpers uses real resolve_field
# see: https://gitlab.com/gitlab-org/gitlab/-/issues/287791
def aliased_args(resolver, args)
definitions = resolver.arguments
def mock_extras(context, parent: :not_given, lookahead: :not_given)
allow(context).to receive(:parent).and_return(parent) unless parent == :not_given
allow(context).to receive(:lookahead).and_return(lookahead) unless lookahead == :not_given
end
args.transform_keys do |k|
definitions[GraphqlHelpers.fieldnamerize(k)]&.keyword || k
# a synthetic BaseObject type to be used in resolver specs. See `GraphqlHelpers#resolve`
def resolver_parent
@resolver_parent ||= fresh_object_type('ResolverParent')
end
def fresh_object_type(name = 'Object')
Class.new(::Types::BaseObject) { graphql_name name }
end
def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema)
......@@ -124,9 +219,9 @@ module GraphqlHelpers
lazy_vals.is_a?(Array) ? lazy_vals.map { |val| sync(val) } : sync(lazy_vals)
end
def graphql_query_for(name, attributes = {}, fields = nil)
def graphql_query_for(name, args = {}, selection = nil)
type = GitlabSchema.types['Query'].fields[GraphqlHelpers.fieldnamerize(name)]&.type
wrap_query(query_graphql_field(name, attributes, fields, type))
wrap_query(query_graphql_field(name, args, selection, type))
end
def wrap_query(query)
......@@ -171,25 +266,6 @@ module GraphqlHelpers
::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h)).to_json
end
def resolve_field(name, object, args = {}, current_user: nil)
q = GraphQL::Query.new(GitlabSchema)
context = GraphQL::Query::Context.new(query: q, object: object, values: { current_user: current_user })
allow(context).to receive(:parent).and_return(nil)
field = described_class.fields.fetch(GraphqlHelpers.fieldnamerize(name))
instance = described_class.authorized_new(object, context)
raise UnauthorizedObject unless instance
field.resolve_field(instance, args, context)
end
def simple_resolver(resolved_value = 'Resolved value')
Class.new(Resolvers::BaseResolver) do
define_method :resolve do |**_args|
resolved_value
end
end
end
# Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys
#
# prepare_input_for_mutation({ 'my_key' => 1 })
......@@ -558,24 +634,26 @@ module GraphqlHelpers
end
end
def execute_query(query_type)
schema = Class.new(GraphQL::Schema) do
use GraphQL::Pagination::Connections
use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Pagination::Connections
lazy_resolve ::Gitlab::Graphql::Lazy, :force
query(query_type)
end
# assumes query_string to be let-bound in the current context
def execute_query(query_type, schema: empty_schema, graphql: query_string)
schema.query(query_type)
schema.execute(
query_string,
graphql,
context: { current_user: user },
variables: {}
)
end
def empty_schema
Class.new(GraphQL::Schema) do
use GraphQL::Pagination::Connections
use Gitlab::Graphql::Pagination::Connections
lazy_resolve ::Gitlab::Graphql::Lazy, :force
end
end
# A lookahead that selects everything
def positive_lookahead
double(selects?: true).tap do |selection|
......@@ -589,6 +667,23 @@ module GraphqlHelpers
allow(selection).to receive(:selection).and_return(selection)
end
end
private
def to_base_field(name_or_field, object_type)
case name_or_field
when ::Types::BaseField
name_or_field
else
field_by_name(name_or_field, object_type)
end
end
def field_by_name(name, object_type)
name = ::GraphqlHelpers.fieldnamerize(name)
object_type.fields[name] || (raise ArgumentError, "Unknown field #{name} for #{described_class.graphql_name}")
end
end
# This warms our schema, doing this as part of loading the helpers to avoid
......
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