449 lines
13 KiB
Ruby
449 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module GraphqlHelpers
|
|
MutationDefinition = Struct.new(:query, :variables)
|
|
|
|
NoData = Class.new(StandardError)
|
|
|
|
# makes an underscored string look like a fieldname
|
|
# "merge_request" => "mergeRequest"
|
|
def self.fieldnamerize(underscored_field_name)
|
|
underscored_field_name.to_s.camelize(:lower)
|
|
end
|
|
|
|
# Run a loader's named resolver in a way that closely mimics the framework.
|
|
#
|
|
# First the `ready?` method is called. If it turns out that the resolver is not
|
|
# ready, then the early return is returned instead.
|
|
#
|
|
# Then the resolve method is called.
|
|
def resolve(resolver_class, obj: nil, args: {}, ctx: {}, field: nil)
|
|
resolver = resolver_class.new(object: obj, context: ctx, field: field)
|
|
ready, early_return = sync_all { resolver.ready?(**args) }
|
|
|
|
return early_return unless ready
|
|
|
|
resolver.resolve(args)
|
|
end
|
|
|
|
# Eagerly run a loader's named resolver
|
|
# (syncs any lazy values returned by resolve)
|
|
def eager_resolve(resolver_class, **opts)
|
|
sync(resolve(resolver_class, **opts))
|
|
end
|
|
|
|
def sync(value)
|
|
if GitlabSchema.lazy?(value)
|
|
GitlabSchema.sync_lazy(value)
|
|
else
|
|
value
|
|
end
|
|
end
|
|
|
|
# Runs a block inside a BatchLoader::Executor wrapper
|
|
def batch(max_queries: nil, &blk)
|
|
wrapper = proc do
|
|
BatchLoader::Executor.ensure_current
|
|
yield
|
|
ensure
|
|
BatchLoader::Executor.clear_current
|
|
end
|
|
|
|
if max_queries
|
|
result = nil
|
|
expect { result = wrapper.call }.not_to exceed_query_limit(max_queries)
|
|
result
|
|
else
|
|
wrapper.call
|
|
end
|
|
end
|
|
|
|
# BatchLoader::GraphQL returns a wrapper, so we need to :sync in order
|
|
# to get the actual values
|
|
def batch_sync(max_queries: nil, &blk)
|
|
batch(max_queries: max_queries) { sync_all(&blk) }
|
|
end
|
|
|
|
def sync_all(&blk)
|
|
lazy_vals = yield
|
|
lazy_vals.is_a?(Array) ? lazy_vals.map { |val| sync(val) } : sync(lazy_vals)
|
|
end
|
|
|
|
def graphql_query_for(name, attributes = {}, fields = nil)
|
|
<<~QUERY
|
|
{
|
|
#{query_graphql_field(name, attributes, fields)}
|
|
}
|
|
QUERY
|
|
end
|
|
|
|
def graphql_mutation(name, input, fields = nil, &block)
|
|
raise ArgumentError, 'Please pass either `fields` parameter or a block to `#graphql_mutation`, but not both.' if fields.present? && block_given?
|
|
|
|
mutation_name = GraphqlHelpers.fieldnamerize(name)
|
|
input_variable_name = "$#{input_variable_name_for_mutation(name)}"
|
|
mutation_field = GitlabSchema.mutation.fields[mutation_name]
|
|
|
|
fields = yield if block_given?
|
|
fields ||= all_graphql_fields_for(mutation_field.type.to_graphql)
|
|
|
|
query = <<~MUTATION
|
|
mutation(#{input_variable_name}: #{mutation_field.arguments['input'].type.to_graphql}) {
|
|
#{mutation_name}(input: #{input_variable_name}) {
|
|
#{fields}
|
|
}
|
|
}
|
|
MUTATION
|
|
variables = variables_for_mutation(name, input)
|
|
|
|
MutationDefinition.new(query, variables)
|
|
end
|
|
|
|
def variables_for_mutation(name, input)
|
|
graphql_input = prepare_input_for_mutation(input)
|
|
|
|
result = { input_variable_name_for_mutation(name) => graphql_input }
|
|
|
|
# Avoid trying to serialize multipart data into JSON
|
|
if graphql_input.values.none? { |value| io_value?(value) }
|
|
result.to_json
|
|
else
|
|
result
|
|
end
|
|
end
|
|
|
|
# Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys
|
|
#
|
|
# prepare_input_for_mutation({ 'my_key' => 1 })
|
|
# => { 'myKey' => 1}
|
|
def prepare_input_for_mutation(input)
|
|
input.map do |name, value|
|
|
value = prepare_input_for_mutation(value) if value.is_a?(Hash)
|
|
|
|
[GraphqlHelpers.fieldnamerize(name), value]
|
|
end.to_h
|
|
end
|
|
|
|
def input_variable_name_for_mutation(mutation_name)
|
|
mutation_name = GraphqlHelpers.fieldnamerize(mutation_name)
|
|
mutation_field = GitlabSchema.mutation.fields[mutation_name]
|
|
input_type = field_type(mutation_field.arguments['input'])
|
|
|
|
GraphqlHelpers.fieldnamerize(input_type)
|
|
end
|
|
|
|
def field_with_params(name, attributes = {})
|
|
namerized = GraphqlHelpers.fieldnamerize(name.to_s)
|
|
return "#{namerized}" if attributes.blank?
|
|
|
|
field_params = if attributes.is_a?(Hash)
|
|
"(#{attributes_to_graphql(attributes)})"
|
|
else
|
|
"(#{attributes})"
|
|
end
|
|
|
|
"#{namerized}#{field_params}"
|
|
end
|
|
|
|
def query_graphql_field(name, attributes = {}, fields = nil)
|
|
<<~QUERY
|
|
#{field_with_params(name, attributes)}
|
|
#{wrap_fields(fields || all_graphql_fields_for(name.to_s.classify))}
|
|
QUERY
|
|
end
|
|
|
|
def wrap_fields(fields)
|
|
fields = Array.wrap(fields).map do |field|
|
|
case field
|
|
when Symbol
|
|
GraphqlHelpers.fieldnamerize(field)
|
|
else
|
|
field
|
|
end
|
|
end.join("\n")
|
|
|
|
return unless fields.present?
|
|
|
|
<<~FIELDS
|
|
{
|
|
#{fields}
|
|
}
|
|
FIELDS
|
|
end
|
|
|
|
def all_graphql_fields_for(class_name, parent_types = Set.new, max_depth: 3, excluded: [])
|
|
# pulling _all_ fields can generate a _huge_ query (like complexity 180,000),
|
|
# and significantly increase spec runtime. so limit the depth by default
|
|
return if max_depth <= 0
|
|
|
|
allow_unlimited_graphql_complexity
|
|
allow_unlimited_graphql_depth
|
|
allow_high_graphql_recursion
|
|
allow_high_graphql_transaction_threshold
|
|
|
|
type = GitlabSchema.types[class_name.to_s]
|
|
return "" unless type
|
|
|
|
type.fields.map do |name, field|
|
|
# We can't guess arguments, so skip fields that require them
|
|
next if required_arguments?(field)
|
|
next if excluded.include?(name)
|
|
|
|
singular_field_type = field_type(field)
|
|
|
|
# If field type is the same as parent type, then we're hitting into
|
|
# mutual dependency. Break it from infinite recursion
|
|
next if parent_types.include?(singular_field_type)
|
|
|
|
if nested_fields?(field)
|
|
fields =
|
|
all_graphql_fields_for(singular_field_type, parent_types | [type], max_depth: max_depth - 1)
|
|
|
|
"#{name} { #{fields} }" unless fields.blank?
|
|
else
|
|
name
|
|
end
|
|
end.compact.join("\n")
|
|
end
|
|
|
|
def attributes_to_graphql(attributes)
|
|
attributes.map do |name, value|
|
|
value_str = as_graphql_literal(value)
|
|
|
|
"#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}"
|
|
end.join(", ")
|
|
end
|
|
|
|
# Fairly dumb Ruby => GraphQL rendering function. Only suitable for testing.
|
|
# Use symbol for Enum values
|
|
def as_graphql_literal(value)
|
|
case value
|
|
when Array then "[#{value.map { |v| as_graphql_literal(v) }.join(',')}]"
|
|
when Integer, Float then value.to_s
|
|
when String then "\"#{value.gsub(/"/, '\\"')}\""
|
|
when Symbol then value
|
|
when nil then 'null'
|
|
when true then 'true'
|
|
when false then 'false'
|
|
else raise ArgumentError, "Cannot represent #{value} as GraphQL literal"
|
|
end
|
|
end
|
|
|
|
def post_multiplex(queries, current_user: nil, headers: {})
|
|
post api('/', current_user, version: 'graphql'), params: { _json: queries }, headers: headers
|
|
end
|
|
|
|
def post_graphql(query, current_user: nil, variables: nil, headers: {})
|
|
post api('/', current_user, version: 'graphql'), params: { query: query, variables: variables }, headers: headers
|
|
end
|
|
|
|
def post_graphql_mutation(mutation, current_user: nil)
|
|
post_graphql(mutation.query, current_user: current_user, variables: mutation.variables)
|
|
end
|
|
|
|
# this implements GraphQL multipart request v2
|
|
# https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2
|
|
# this is simplified and do not support file deduplication
|
|
def mutation_to_apollo_uploads_param(mutation, files: [])
|
|
operations = { 'query' => mutation.query, 'variables' => mutation.variables }
|
|
map = {}
|
|
extracted_files = {}
|
|
|
|
files.each_with_index do |file_path, idx|
|
|
apollo_idx = (idx + 1).to_s
|
|
parent_dig_path = file_path[0..-2]
|
|
file_key = file_path[-1]
|
|
|
|
parent = operations['variables']
|
|
parent = parent.dig(*parent_dig_path) unless parent_dig_path.empty?
|
|
|
|
extracted_files[apollo_idx] = parent[file_key]
|
|
parent[file_key] = nil
|
|
|
|
map[apollo_idx] = ["variables.#{file_path.join('.')}"]
|
|
end
|
|
|
|
{ operations: operations.to_json, map: map.to_json }.merge(extracted_files)
|
|
end
|
|
|
|
# Raises an error if no data is found
|
|
def graphql_data
|
|
# Note that `json_response` is defined as `let(:json_response)` and
|
|
# therefore, in a spec with multiple queries, will only contain data
|
|
# from the _first_ query, not subsequent ones
|
|
json_response['data'] || (raise NoData, graphql_errors)
|
|
end
|
|
|
|
def graphql_data_at(*path)
|
|
graphql_dig_at(graphql_data, *path)
|
|
end
|
|
|
|
def graphql_dig_at(data, *path)
|
|
keys = path.map { |segment| segment.is_a?(Integer) ? segment : GraphqlHelpers.fieldnamerize(segment) }
|
|
|
|
# Allows for array indexing, like this
|
|
# ['project', 'boards', 'edges', 0, 'node', 'lists']
|
|
keys.reduce(data) do |memo, key|
|
|
memo.is_a?(Array) ? memo[key] : memo&.dig(key)
|
|
end
|
|
end
|
|
|
|
def graphql_errors
|
|
case json_response
|
|
when Hash # regular query
|
|
json_response['errors']
|
|
when Array # multiplexed queries
|
|
json_response.map { |response| response['errors'] }
|
|
else
|
|
raise "Unknown GraphQL response type #{json_response.class}"
|
|
end
|
|
end
|
|
|
|
def expect_graphql_errors_to_include(regexes_to_match)
|
|
raise "No errors. Was expecting to match #{regexes_to_match}" if graphql_errors.nil? || graphql_errors.empty?
|
|
|
|
error_messages = flattened_errors.collect { |error_hash| error_hash["message"] }
|
|
Array.wrap(regexes_to_match).flatten.each do |regex|
|
|
expect(error_messages).to include a_string_matching regex
|
|
end
|
|
end
|
|
|
|
def expect_graphql_errors_to_be_empty
|
|
expect(flattened_errors).to be_empty
|
|
end
|
|
|
|
def flattened_errors
|
|
Array.wrap(graphql_errors).flatten.compact
|
|
end
|
|
|
|
# Raises an error if no response is found
|
|
def graphql_mutation_response(mutation_name)
|
|
graphql_data.fetch(GraphqlHelpers.fieldnamerize(mutation_name))
|
|
end
|
|
|
|
def scalar_fields_of(type_name)
|
|
GitlabSchema.types[type_name].fields.map do |name, field|
|
|
next if nested_fields?(field) || required_arguments?(field)
|
|
|
|
name
|
|
end.compact
|
|
end
|
|
|
|
def nested_fields_of(type_name)
|
|
GitlabSchema.types[type_name].fields.map do |name, field|
|
|
next if !nested_fields?(field) || required_arguments?(field)
|
|
|
|
[name, field]
|
|
end.compact
|
|
end
|
|
|
|
def nested_fields?(field)
|
|
!scalar?(field) && !enum?(field)
|
|
end
|
|
|
|
def scalar?(field)
|
|
field_type(field).kind.scalar?
|
|
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 io_value?(value)
|
|
Array.wrap(value).any? { |v| v.respond_to?(:to_io) }
|
|
end
|
|
|
|
def field_type(field)
|
|
field_type = field.type.respond_to?(:to_graphql) ? field.type.to_graphql : field.type
|
|
|
|
# The type could be nested. For example `[GraphQL::STRING_TYPE]`:
|
|
# - List
|
|
# - String!
|
|
# - String
|
|
field_type = field_type.of_type while field_type.respond_to?(:of_type)
|
|
|
|
field_type
|
|
end
|
|
|
|
# for most tests, we want to allow unlimited complexity
|
|
def allow_unlimited_graphql_complexity
|
|
allow_any_instance_of(GitlabSchema).to receive(:max_complexity).and_return nil
|
|
allow(GitlabSchema).to receive(:max_query_complexity).with(any_args).and_return nil
|
|
end
|
|
|
|
def allow_unlimited_graphql_depth
|
|
allow_any_instance_of(GitlabSchema).to receive(:max_depth).and_return nil
|
|
allow(GitlabSchema).to receive(:max_query_depth).with(any_args).and_return nil
|
|
end
|
|
|
|
def allow_high_graphql_recursion
|
|
allow_any_instance_of(Gitlab::Graphql::QueryAnalyzers::RecursionAnalyzer).to receive(:recursion_threshold).and_return 1000
|
|
end
|
|
|
|
def allow_high_graphql_transaction_threshold
|
|
stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 1000)
|
|
end
|
|
|
|
def node_array(data, extract_attribute = nil)
|
|
data.map do |item|
|
|
extract_attribute ? item['node'][extract_attribute] : item['node']
|
|
end
|
|
end
|
|
|
|
def global_id_of(model)
|
|
model.to_global_id.to_s
|
|
end
|
|
|
|
def missing_required_argument(path, argument)
|
|
a_hash_including(
|
|
'path' => ['query'].concat(path),
|
|
'extensions' => a_hash_including('code' => 'missingRequiredArguments', 'arguments' => argument.to_s)
|
|
)
|
|
end
|
|
|
|
def custom_graphql_error(path, msg)
|
|
a_hash_including('path' => path, 'message' => msg)
|
|
end
|
|
|
|
def type_factory
|
|
Class.new(Types::BaseObject) do
|
|
graphql_name 'TestType'
|
|
|
|
field :name, GraphQL::STRING_TYPE, null: true
|
|
|
|
yield(self) if block_given?
|
|
end
|
|
end
|
|
|
|
def query_factory
|
|
Class.new(Types::BaseObject) do
|
|
graphql_name 'TestQuery'
|
|
|
|
yield(self) if block_given?
|
|
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
|
|
|
|
query(query_type)
|
|
end
|
|
|
|
schema.execute(
|
|
query_string,
|
|
context: { current_user: user },
|
|
variables: {}
|
|
)
|
|
end
|
|
end
|
|
|
|
# This warms our schema, doing this as part of loading the helpers to avoid
|
|
# duplicate loading error when Rails tries autoload the types.
|
|
GitlabSchema.graphql_definition
|