diff options
Diffstat (limited to 'spec/support/helpers/graphql_helpers.rb')
-rw-r--r-- | spec/support/helpers/graphql_helpers.rb | 206 |
1 files changed, 127 insertions, 79 deletions
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index a1b4e6eee92..b20801bd3c4 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -4,6 +4,11 @@ module GraphqlHelpers MutationDefinition = Struct.new(:query, :variables) NoData = Class.new(StandardError) + UnauthorizedObject = Class.new(StandardError) + + def graphql_args(**values) + ::Graphql::Arguments.new(values) + end # makes an underscored string look like a fieldname # "merge_request" => "mergeRequest" @@ -17,7 +22,10 @@ module GraphqlHelpers # ready, then the early return is returned instead. # # Then the resolve method is called. - def resolve(resolver_class, args: {}, **resolver_args) + 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) } @@ -26,6 +34,16 @@ module GraphqlHelpers resolver.resolve(**args) 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 + + args.transform_keys do |k| + definitions[GraphqlHelpers.fieldnamerize(k)]&.keyword || k + end + end + def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema) if ctx.is_a?(Hash) q = double('Query', schema: schema) @@ -111,24 +129,33 @@ module GraphqlHelpers def variables_for_mutation(name, input) graphql_input = prepare_input_for_mutation(input) - result = { input_variable_name_for_mutation(name) => graphql_input } + { input_variable_name_for_mutation(name) => graphql_input } + end - # Avoid trying to serialize multipart data into JSON - if graphql_input.values.none? { |value| io_value?(value) } - result.to_json - else - result - end + def serialize_variables(variables) + return unless variables + return variables if variables.is_a?(String) + + ::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h)).to_json end - def resolve_field(name, object, args = {}) - context = double("Context", - schema: GitlabSchema, - query: GraphQL::Query.new(GitlabSchema), - parent: nil) - field = described_class.fields[name] + 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) - field.resolve_field(instance, {}, 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 @@ -165,10 +192,32 @@ module GraphqlHelpers 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 + attributes, fields = [nil, attributes] if fields.nil? && !attributes.is_a?(Hash) + + field = field_with_params(name, attributes) + + field + wrap_fields(fields || all_graphql_fields_for(name.to_s.classify)).to_s + end + + def page_info_selection + "pageInfo { hasNextPage hasPreviousPage endCursor startCursor }" + end + + def query_nodes(name, fields = nil, args: nil, of: name, include_pagination_info: false, max_depth: 1) + fields ||= all_graphql_fields_for(of.to_s.classify, max_depth: max_depth) + node_selection = include_pagination_info ? "#{page_info_selection} nodes" : :nodes + query_graphql_path([[name, args], node_selection], fields) + end + + # e.g: + # query_graphql_path(%i[foo bar baz], all_graphql_fields_for('Baz')) + # => foo { bar { baz { x y z } } } + def query_graphql_path(segments, fields = nil) + # we really want foldr here... + segments.reverse.reduce(fields) do |tail, segment| + name, args = Array.wrap(segment) + query_graphql_field(name, args, tail) + end end def wrap_fields(fields) @@ -203,50 +252,22 @@ module GraphqlHelpers 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) + # We can't guess arguments, so skip fields that require them + skip = ->(name, field) { excluded.include?(name) || required_arguments?(field) } - 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") + ::Graphql::FieldSelection.select_fields(type, skip, parent_types, max_depth) end - def attributes_to_graphql(attributes) - attributes.map do |name, value| - value_str = as_graphql_literal(value) + def with_signature(variables, query) + %Q[query(#{variables.map(&:sig).join(', ')}) #{query}] + end - "#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}" - end.join(", ") + def var(type) + ::Graphql::Var.new(generate(:variable), type) 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 Hash then "{#{attributes_to_graphql(value)}}" - 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 + def attributes_to_graphql(arguments) + ::Graphql::Arguments.new(arguments).to_s end def post_multiplex(queries, current_user: nil, headers: {}) @@ -254,7 +275,7 @@ module GraphqlHelpers end def post_graphql(query, current_user: nil, variables: nil, headers: {}) - params = { query: query, variables: variables&.to_json } + params = { query: query, variables: serialize_variables(variables) } post api('/', current_user, version: 'graphql'), params: params, headers: headers end @@ -320,36 +341,47 @@ module GraphqlHelpers { operations: operations.to_json, map: map.to_json }.merge(extracted_files) end + def fresh_response_data + Gitlab::Json.parse(response.body) + end + # Raises an error if no data is found - def graphql_data + def graphql_data(body = json_response) # 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) + body['data'] || (raise NoData, graphql_errors(body)) end def graphql_data_at(*path) graphql_dig_at(graphql_data, *path) end + # Slightly more powerful than just `dig`: + # - also supports implicit flat-mapping (.e.g. :foo :nodes :bar :nodes) 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) + if memo.is_a?(Array) + key.is_a?(Integer) ? memo[key] : memo.flat_map { |e| Array.wrap(e[key]) } + else + memo&.dig(key) + end end end - def graphql_errors - case json_response + # See note at graphql_data about memoization and multiple requests + def graphql_errors(body = json_response) + case body when Hash # regular query - json_response['errors'] + body['errors'] when Array # multiplexed queries - json_response.map { |response| response['errors'] } + body.map { |response| response['errors'] } else - raise "Unknown GraphQL response type #{json_response.class}" + raise "Unknown GraphQL response type #{body.class}" end end @@ -392,19 +424,29 @@ module GraphqlHelpers end def nested_fields?(field) - !scalar?(field) && !enum?(field) + ::Graphql::FieldInspection.new(field).nested_fields? end def scalar?(field) - field_type(field).kind.scalar? + ::Graphql::FieldInspection.new(field).scalar? end def enum?(field) - field_type(field).kind.enum? + ::Graphql::FieldInspection.new(field).enum? end + # There are a few non BaseField fields in our schema (pageInfo for one). + # None of them require arguments. def required_arguments?(field) - field.arguments.values.any? { |argument| argument.type.non_null? } + return field.requires_argument? if field.is_a?(::Types::BaseField) + + if (meta = field.try(:metadata)) && meta[:type_class] + required_arguments?(meta[:type_class]) + elsif args = field.try(:arguments) + args.values.any? { |argument| argument.type.non_null? } + else + false + end end def io_value?(value) @@ -412,15 +454,7 @@ module GraphqlHelpers 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 + ::Graphql::FieldInspection.new(field).type end # for most tests, we want to allow unlimited complexity @@ -498,6 +532,20 @@ module GraphqlHelpers variables: {} ) end + + # A lookahead that selects everything + def positive_lookahead + double(selects?: true).tap do |selection| + allow(selection).to receive(:selection).and_return(selection) + end + end + + # A lookahead that selects nothing + def negative_lookahead + double(selects?: false).tap do |selection| + allow(selection).to receive(:selection).and_return(selection) + end + end end # This warms our schema, doing this as part of loading the helpers to avoid |