From 9c6c17cbcdb8bf8185fc1b873dcfd08f723e4df5 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Wed, 16 Aug 2017 14:04:41 +0100 Subject: [PATCH] Add a minimal GraphQL API --- .rubocop.yml | 1 + Gemfile | 6 ++ Gemfile.lock | 17 +++++ app/controllers/graphql_controller.rb | 49 ++++++++++++++ app/graphql/gitlab_schema.rb | 11 ++++ app/graphql/loaders/base_loader.rb | 24 +++++++ app/graphql/loaders/full_path_loader.rb | 23 +++++++ app/graphql/loaders/iid_loader.rb | 35 ++++++++++ app/graphql/mutations/.keep | 0 app/graphql/types/merge_request_type.rb | 50 +++++++++++++++ app/graphql/types/mutation_type.rb | 5 ++ app/graphql/types/project_type.rb | 62 ++++++++++++++++++ app/graphql/types/query_type.rb | 38 +++++++++++ app/graphql/types/time_type.rb | 8 +++ config/dependency_decisions.yml | 6 ++ config/routes/api.rb | 3 + lib/gitlab/graphql/authorize.rb | 55 ++++++++++++++++ spec/controllers/graphql_controller_spec.rb | 58 +++++++++++++++++ spec/graphql/gitlab_schema_spec.rb | 27 ++++++++ spec/graphql/loaders/full_path_loader_spec.rb | 38 +++++++++++ spec/graphql/loaders/iid_loader_spec.rb | 64 +++++++++++++++++++ spec/graphql/types/query_type_spec.rb | 37 +++++++++++ spec/graphql/types/time_type_spec.rb | 16 +++++ spec/lib/gitlab/path_regex_spec.rb | 12 ++-- spec/support/helpers/graphql_helpers.rb | 29 +++++++++ spec/support/matchers/graphql_matchers.rb | 31 +++++++++ 26 files changed, 700 insertions(+), 5 deletions(-) create mode 100644 app/controllers/graphql_controller.rb create mode 100644 app/graphql/gitlab_schema.rb create mode 100644 app/graphql/loaders/base_loader.rb create mode 100644 app/graphql/loaders/full_path_loader.rb create mode 100644 app/graphql/loaders/iid_loader.rb create mode 100644 app/graphql/mutations/.keep create mode 100644 app/graphql/types/merge_request_type.rb create mode 100644 app/graphql/types/mutation_type.rb create mode 100644 app/graphql/types/project_type.rb create mode 100644 app/graphql/types/query_type.rb create mode 100644 app/graphql/types/time_type.rb create mode 100644 lib/gitlab/graphql/authorize.rb create mode 100644 spec/controllers/graphql_controller_spec.rb create mode 100644 spec/graphql/gitlab_schema_spec.rb create mode 100644 spec/graphql/loaders/full_path_loader_spec.rb create mode 100644 spec/graphql/loaders/iid_loader_spec.rb create mode 100644 spec/graphql/types/query_type_spec.rb create mode 100644 spec/graphql/types/time_type_spec.rb create mode 100644 spec/support/helpers/graphql_helpers.rb create mode 100644 spec/support/matchers/graphql_matchers.rb diff --git a/.rubocop.yml b/.rubocop.yml index 0582bfe8d70..2639a33f363 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -43,6 +43,7 @@ Naming/FileName: - 'config/**/*' - 'lib/generators/**/*' - 'ee/lib/generators/**/*' + - 'app/graphql/**/*' IgnoreExecutableScripts: true AllowedAcronyms: - EE diff --git a/Gemfile b/Gemfile index 90fa659fe78..134b726c9eb 100644 --- a/Gemfile +++ b/Gemfile @@ -93,6 +93,12 @@ gem 'grape', '~> 1.0' gem 'grape-entity', '~> 0.7.1' gem 'rack-cors', '~> 1.0.0', require: 'rack/cors' +# GraphQL API +gem 'graphql', '~> 1.7.14' +gem 'graphql-batch', '~> 0.3.9' +gem 'graphql-preload', '~> 2.0.0' +gem 'graphiql-rails', '~> 1.4.10' + # Disable strong_params so that Mash does not respond to :permitted? gem 'hashie-forbidden_attributes' diff --git a/Gemfile.lock b/Gemfile.lock index 2daaa3b516e..68dd0fba256 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -365,6 +365,18 @@ GEM rake (~> 12) grape_logging (1.7.0) grape + graphiql-rails (1.4.10) + railties + sprockets-rails + graphql (1.7.14) + graphql-batch (0.3.9) + graphql (>= 0.8, < 2) + promise.rb (~> 0.7.2) + graphql-preload (2.0.1) + activerecord (>= 4.1, < 6) + graphql (>= 1.5, < 2) + graphql-batch (~> 0.3) + promise.rb (~> 0.7) grpc (1.11.0) google-protobuf (~> 3.1) googleapis-common-protos-types (~> 1.0.0) @@ -627,6 +639,7 @@ GEM unparser procto (0.0.3) prometheus-client-mmap (0.9.3) + promise.rb (0.7.4) pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) @@ -1053,6 +1066,10 @@ DEPENDENCIES grape-entity (~> 0.7.1) grape-path-helpers (~> 1.0) grape_logging (~> 1.7) + graphiql-rails (~> 1.4.10) + graphql (~> 1.7.14) + graphql-batch (~> 0.3.9) + graphql-preload (~> 2.0.0) grpc (~> 1.11.0) haml_lint (~> 0.26.0) hamlit (~> 2.6.1) diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb new file mode 100644 index 00000000000..ef258bf07cb --- /dev/null +++ b/app/controllers/graphql_controller.rb @@ -0,0 +1,49 @@ +class GraphqlController < ApplicationController + # Unauthenticated users have access to the API for public data + skip_before_action :authenticate_user! + + before_action :check_graphql_feature_flag! + + def execute + variables = ensure_hash(params[:variables]) + query = params[:query] + operation_name = params[:operationName] + context = { + current_user: current_user + } + result = GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name) + render json: result + end + + private + + # Overridden from the ApplicationController to make the response look like + # a GraphQL response. That is nicely picked up in Graphiql. + def render_404 + error = { errors: [ message: "Not found" ] } + + render json: error, status: :not_found + end + + def check_graphql_feature_flag! + render_404 unless Feature.enabled?(:graphql) + end + + # Handle form data, JSON body, or a blank value + def ensure_hash(ambiguous_param) + case ambiguous_param + when String + if ambiguous_param.present? + ensure_hash(JSON.parse(ambiguous_param)) + else + {} + end + when Hash, ActionController::Parameters + ambiguous_param + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{ambiguous_param}" + end + end +end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb new file mode 100644 index 00000000000..7392bf6f503 --- /dev/null +++ b/app/graphql/gitlab_schema.rb @@ -0,0 +1,11 @@ +Gitlab::Graphql::Authorize.register! + +GitlabSchema = GraphQL::Schema.define do + use GraphQL::Batch + + enable_preloading + enable_authorization + + mutation(Types::MutationType) + query(Types::QueryType) +end diff --git a/app/graphql/loaders/base_loader.rb b/app/graphql/loaders/base_loader.rb new file mode 100644 index 00000000000..c32c4daa91a --- /dev/null +++ b/app/graphql/loaders/base_loader.rb @@ -0,0 +1,24 @@ +# Helper methods for all loaders +class Loaders::BaseLoader < GraphQL::Batch::Loader + # Convert a class method into a resolver proc. The method should follow the + # (obj, args, ctx) calling convention + class << self + def [](sym) + resolver = method(sym) + raise ArgumentError.new("#{self}.#{sym} is not a resolver") unless resolver.arity == 3 + + resolver + end + end + + # Fulfill all keys. Pass a block that converts each result into a key. + # Any keys not in results will be fulfilled with nil. + def fulfill_all(results, keys, &key_blk) + results.each do |result| + key = yield result + fulfill(key, result) + end + + keys.each { |key| fulfill(key, nil) unless fulfilled?(key) } + end +end diff --git a/app/graphql/loaders/full_path_loader.rb b/app/graphql/loaders/full_path_loader.rb new file mode 100644 index 00000000000..f99b487ce5d --- /dev/null +++ b/app/graphql/loaders/full_path_loader.rb @@ -0,0 +1,23 @@ +class Loaders::FullPathLoader < Loaders::BaseLoader + class << self + def project(obj, args, ctx) + project_by_full_path(args[:full_path]) + end + + def project_by_full_path(full_path) + self.for(Project).load(full_path) + end + end + + attr_reader :model + + def initialize(model) + @model = model + end + + def perform(keys) + # `with_route` prevents relation.all.map(&:full_path)` from being N+1 + relation = model.where_full_path_in(keys).with_route + fulfill_all(relation, keys, &:full_path) + end +end diff --git a/app/graphql/loaders/iid_loader.rb b/app/graphql/loaders/iid_loader.rb new file mode 100644 index 00000000000..e89031da0c2 --- /dev/null +++ b/app/graphql/loaders/iid_loader.rb @@ -0,0 +1,35 @@ +class Loaders::IidLoader < Loaders::BaseLoader + class << self + def merge_request(obj, args, ctx) + iid = args[:iid] + promise = Loaders::FullPathLoader.project_by_full_path(args[:project]) + + promise.then do |project| + if project + merge_request_by_project_and_iid(project.id, iid) + else + nil + end + end + end + + def merge_request_by_project_and_iid(project_id, iid) + self.for(MergeRequest, target_project_id: project_id.to_s).load(iid.to_s) + end + end + + attr_reader :model, :restrictions + + def initialize(model, restrictions = {}) + @model = model + @restrictions = restrictions + end + + def perform(keys) + relation = model.where(iid: keys) + relation = relation.where(restrictions) if restrictions.present? + + # IIDs are represented as the GraphQL `id` type, which is a string + fulfill_all(relation, keys) { |instance| instance.iid.to_s } + end +end diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb new file mode 100644 index 00000000000..9b12f6f2bf3 --- /dev/null +++ b/app/graphql/types/merge_request_type.rb @@ -0,0 +1,50 @@ +Types::MergeRequestType = GraphQL::ObjectType.define do + name 'MergeRequest' + + field :id, !types.ID + field :iid, !types.ID + field :title, types.String + field :description, types.String + field :state, types.String + + field :created_at, Types::TimeType + field :updated_at, Types::TimeType + + field :source_project, -> { Types::ProjectType } + field :target_project, -> { Types::ProjectType } + + # Alias for target_project + field :project, -> { Types::ProjectType } + + field :source_project_id, types.Int + field :target_project_id, types.Int + field :project_id, types.Int + + field :source_branch, types.String + field :target_branch, types.String + + field :work_in_progress, types.Boolean, property: :work_in_progress? + field :merge_when_pipeline_succeeds, types.Boolean + + field :sha, types.String, property: :diff_head_sha + field :merge_commit_sha, types.String + + field :user_notes_count, types.Int + field :should_remove_source_branch, types.Boolean, property: :should_remove_source_branch? + field :force_remove_source_branch, types.Boolean, property: :force_remove_source_branch? + + field :merge_status, types.String + + field :web_url, types.String do + resolve ->(merge_request, args, ctx) { Gitlab::UrlBuilder.build(merge_request) } + end + + field :upvotes, types.Int + field :downvotes, types.Int + + field :subscribed, types.Boolean do + resolve ->(merge_request, args, ctx) do + merge_request.subscribed?(ctx[:current_user], merge_request.target_project) + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb new file mode 100644 index 00000000000..c5061f10239 --- /dev/null +++ b/app/graphql/types/mutation_type.rb @@ -0,0 +1,5 @@ +Types::MutationType = GraphQL::ObjectType.define do + name "Mutation" + + # TODO: Add Mutations as fields +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb new file mode 100644 index 00000000000..bfefc594896 --- /dev/null +++ b/app/graphql/types/project_type.rb @@ -0,0 +1,62 @@ +Types::ProjectType = GraphQL::ObjectType.define do + name 'Project' + + field :id, !types.ID + + field :full_path, !types.ID + field :path, !types.String + + field :name_with_namespace, !types.String + field :name, !types.String + + field :description, types.String + + field :default_branch, types.String + field :tag_list, types.String + + field :ssh_url_to_repo, types.String + field :http_url_to_repo, types.String + field :web_url, types.String + + field :star_count, !types.Int + field :forks_count, !types.Int + + field :created_at, Types::TimeType + field :last_activity_at, Types::TimeType + + field :archived, types.Boolean + + field :visibility, types.String + + field :container_registry_enabled, types.Boolean + field :shared_runners_enabled, types.Boolean + field :lfs_enabled, types.Boolean + + field :avatar_url, types.String do + resolve ->(project, args, ctx) { project.avatar_url(only_path: false) } + end + + %i[issues merge_requests wiki snippets].each do |feature| + field "#{feature}_enabled", types.Boolean do + resolve ->(project, args, ctx) { project.feature_available?(feature, ctx[:current_user]) } + end + end + + field :jobs_enabled, types.Boolean do + resolve ->(project, args, ctx) { project.feature_available?(:builds, ctx[:current_user]) } + end + + field :public_jobs, types.Boolean, property: :public_builds + + field :open_issues_count, types.Int do + resolve ->(project, args, ctx) { project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) } + end + + field :import_status, types.String + field :ci_config_path, types.String + + field :only_allow_merge_if_pipeline_succeeds, types.Boolean + field :request_access_enabled, types.Boolean + field :only_allow_merge_if_all_discussions_are_resolved, types.Boolean + field :printing_merge_request_link_enabled, types.Boolean +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb new file mode 100644 index 00000000000..029bbd098ad --- /dev/null +++ b/app/graphql/types/query_type.rb @@ -0,0 +1,38 @@ +Types::QueryType = GraphQL::ObjectType.define do + name 'Query' + + field :project, Types::ProjectType do + argument :full_path, !types.ID do + description 'The full path of the project, e.g., "gitlab-org/gitlab-ce"' + end + + authorize :read_project + + resolve Loaders::FullPathLoader[:project] + end + + field :merge_request, Types::MergeRequestType do + argument :project, !types.ID do + description 'The full path of the target project, e.g., "gitlab-org/gitlab-ce"' + end + + argument :iid, !types.ID do + description 'The IID of the merge request, e.g., "1"' + end + + authorize :read_merge_request + + resolve Loaders::IidLoader[:merge_request] + end + + # Testing endpoint to validate the API with + field :echo, types.String do + argument :text, types.String + + resolve -> (obj, args, ctx) do + username = ctx[:current_user]&.username + + "#{username.inspect} says: #{args[:text]}" + end + end +end diff --git a/app/graphql/types/time_type.rb b/app/graphql/types/time_type.rb new file mode 100644 index 00000000000..fb717eb3dc7 --- /dev/null +++ b/app/graphql/types/time_type.rb @@ -0,0 +1,8 @@ +# Taken from http://www.rubydoc.info/github/rmosolgo/graphql-ruby/GraphQL/ScalarType +Types::TimeType = GraphQL::ScalarType.define do + name 'Time' + description 'Time since epoch in fractional seconds' + + coerce_input ->(value, ctx) { Time.at(Float(value)) } + coerce_result ->(value, ctx) { value.to_f } +end diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index 6616b85129e..55a8c9dac9b 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -534,3 +534,9 @@ :why: https://github.com/squaremo/bitsyntax-js/blob/master/LICENSE-MIT :versions: [] :when: 2018-02-20 22:20:25.958123000 Z + - promise.rb + - Unlicense + - :who: + :why: + :versions: [] + :when: 2017-09-05 13:10:22.752422892 Z diff --git a/config/routes/api.rb b/config/routes/api.rb index ce7a7c88900..54ce6d01df0 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -1,2 +1,5 @@ +post '/api/graphql', to: 'graphql#execute' +mount GraphiQL::Rails::Engine, at: '/api/graphiql', graphql_path: '/api/graphql' + API::API.logger Rails.logger mount API::API => '/' diff --git a/lib/gitlab/graphql/authorize.rb b/lib/gitlab/graphql/authorize.rb new file mode 100644 index 00000000000..1df130e519a --- /dev/null +++ b/lib/gitlab/graphql/authorize.rb @@ -0,0 +1,55 @@ +module Gitlab + module Graphql + # Allow fields to declare permissions their objects must have. The field + # will be set to nil unless all required permissions are present. + class Authorize + SETUP_PROC = -> (type, *args) do + type.metadata[:authorize] ||= [] + type.metadata[:authorize].concat(args) + end + + INSTRUMENT_PROC = -> (schema) do + schema.instrument(:field, new) + end + + def self.register! + GraphQL::Schema.accepts_definitions(enable_authorization: INSTRUMENT_PROC) + GraphQL::Field.accepts_definitions(authorize: SETUP_PROC) + end + + # Replace the resolver for the field with one that will only return the + # resolved object if the permissions check is successful. + # + # Collections are not supported. Apply permissions checks for those at the + # database level instead, to avoid loading superfluous data from the DB + def instrument(_type, field) + return field unless field.metadata.include?(:authorize) + + old_resolver = field.resolve_proc + + new_resolver = -> (obj, args, ctx) do + resolved_obj = old_resolver.call(obj, args, ctx) + checker = build_checker(ctx[:current_user], field.metadata[:authorize]) + + if resolved_obj.respond_to?(:then) + resolved_obj.then(&checker) + else + checker.call(resolved_obj) + end + end + + field.redefine do + resolve(new_resolver) + end + end + + private + + def build_checker(current_user, abilities) + proc do |obj| + obj if abilities.all? { |ability| Ability.allowed?(current_user, ability, obj) } + end + end + end + end +end diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb new file mode 100644 index 00000000000..d6689dbc3c6 --- /dev/null +++ b/spec/controllers/graphql_controller_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe GraphqlController do + describe 'execute' do + before do + sign_in(user) if user + + run_test_query! + end + + subject { query_response } + + context 'graphql is disabled by feature flag' do + let(:user) { nil } + + before do + stub_feature_flags(graphql: false) + end + + it 'returns 404' do + run_test_query! + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'signed out' do + let(:user) { nil } + + it 'runs the query with current_user: nil' do + is_expected.to eq('echo' => 'nil says: test success') + end + end + + context 'signed in' do + let(:user) { create(:user, username: 'Simon') } + + it 'runs the query with current_user set' do + is_expected.to eq('echo' => '"Simon" says: test success') + end + end + end + + # Chosen to exercise all the moving parts in GraphqlController#execute + def run_test_query! + query = <<~QUERY + query Echo($text: String) { + echo(text: $text) + } + QUERY + + post :execute, query: query, operationName: 'Echo', variables: { 'text' => 'test success' } + end + + def query_response + json_response['data'] + end +end diff --git a/spec/graphql/gitlab_schema_spec.rb b/spec/graphql/gitlab_schema_spec.rb new file mode 100644 index 00000000000..3582f297866 --- /dev/null +++ b/spec/graphql/gitlab_schema_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe GitlabSchema do + it 'uses batch loading' do + expect(described_class.instrumenters[:multiplex]).to include(GraphQL::Batch::SetupMultiplex) + end + + it 'enables the preload instrumenter' do + expect(field_instrumenters).to include(instance_of(::GraphQL::Preload::Instrument)) + end + + it 'enables the authorization instrumenter' do + expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Authorize)) + end + + it 'has the base mutation' do + expect(described_class.mutation).to eq(::Types::MutationType) + end + + it 'has the base query' do + expect(described_class.query).to eq(::Types::QueryType) + end + + def field_instrumenters + described_class.instrumenters[:field] + end +end diff --git a/spec/graphql/loaders/full_path_loader_spec.rb b/spec/graphql/loaders/full_path_loader_spec.rb new file mode 100644 index 00000000000..2a473239550 --- /dev/null +++ b/spec/graphql/loaders/full_path_loader_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe Loaders::FullPathLoader do + include GraphqlHelpers + + set(:project1) { create(:project) } + set(:project2) { create(:project) } + + set(:other_project) { create(:project) } + + describe '.project' do + it 'batch-resolves projects by full path' do + paths = [project1.full_path, project2.full_path] + + result = batch(max_queries: 1) do + paths.map { |path| resolve_project(path) } + end + + expect(result).to contain_exactly(project1, project2) + end + + it 'resolves an unknown full_path to nil' do + result = batch { resolve_project('unknown/project') } + + expect(result).to be_nil + end + + it 'returns a promise' do + batch do + expect(resolve_project(project1.full_path)).to be_a(Promise) + end + end + end + + def resolve_project(full_path) + resolve(described_class, :project, args: { full_path: full_path }) + end +end diff --git a/spec/graphql/loaders/iid_loader_spec.rb b/spec/graphql/loaders/iid_loader_spec.rb new file mode 100644 index 00000000000..8a0c1f0791a --- /dev/null +++ b/spec/graphql/loaders/iid_loader_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe Loaders::IidLoader do + include GraphqlHelpers + + set(:project) { create(:project, :repository) } + set(:merge_request_1) { create(:merge_request, :simple, source_project: project, target_project: project) } + set(:merge_request_2) { create(:merge_request, :rebased, source_project: project, target_project: project) } + + set(:other_project) { create(:project, :repository) } + set(:other_merge_request) { create(:merge_request, source_project: other_project, target_project: other_project) } + + let(:full_path) { project.full_path } + let(:iid_1) { merge_request_1.iid } + let(:iid_2) { merge_request_2.iid } + + let(:other_full_path) { other_project.full_path } + let(:other_iid) { other_merge_request.iid } + + describe '.merge_request' do + it 'batch-resolves merge requests by target project full path and IID' do + path = full_path # avoid database query + + result = batch(max_queries: 2) do + [resolve_mr(path, iid_1), resolve_mr(path, iid_2)] + end + + expect(result).to contain_exactly(merge_request_1, merge_request_2) + end + + it 'can batch-resolve merge requests from different projects' do + path = project.full_path # avoid database queries + other_path = other_full_path + + result = batch(max_queries: 3) do + [resolve_mr(path, iid_1), resolve_mr(path, iid_2), resolve_mr(other_path, other_iid)] + end + + expect(result).to contain_exactly(merge_request_1, merge_request_2, other_merge_request) + end + + it 'resolves an unknown iid to nil' do + result = batch { resolve_mr(full_path, -1) } + + expect(result).to be_nil + end + + it 'resolves a known iid for an unknown full_path to nil' do + result = batch { resolve_mr('unknown/project', iid_1) } + + expect(result).to be_nil + end + + it 'returns a promise' do + batch do + expect(resolve_mr(full_path, iid_1)).to be_a(Promise) + end + end + end + + def resolve_mr(full_path, iid) + resolve(described_class, :merge_request, args: { project: full_path, iid: iid }) + end +end diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb new file mode 100644 index 00000000000..17d9395504c --- /dev/null +++ b/spec/graphql/types/query_type_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe GitlabSchema.types['Query'] do + it 'is called Query' do + expect(described_class.name).to eq('Query') + end + + it { is_expected.to have_graphql_fields(:project, :merge_request, :echo) } + + describe 'project field' do + subject { described_class.fields['project'] } + + it 'finds projects by full path' do + is_expected.to have_graphql_arguments(:full_path) + is_expected.to have_graphql_type(Types::ProjectType) + is_expected.to have_graphql_resolver(Loaders::FullPathLoader[:project]) + end + + it 'authorizes with read_project' do + is_expected.to require_graphql_authorizations(:read_project) + end + end + + describe 'merge_request field' do + subject { described_class.fields['merge_request'] } + + it 'finds MRs by project and IID' do + is_expected.to have_graphql_arguments(:project, :iid) + is_expected.to have_graphql_type(Types::MergeRequestType) + is_expected.to have_graphql_resolver(Loaders::IidLoader[:merge_request]) + end + + it 'authorizes with read_merge_request' do + is_expected.to require_graphql_authorizations(:read_merge_request) + end + end +end diff --git a/spec/graphql/types/time_type_spec.rb b/spec/graphql/types/time_type_spec.rb new file mode 100644 index 00000000000..087655cc67d --- /dev/null +++ b/spec/graphql/types/time_type_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe GitlabSchema.types['Time'] do + let(:float) { 1504630455.96215 } + let(:time) { Time.at(float) } + + it { expect(described_class.name).to eq('Time') } + + it 'coerces Time into fractional seconds since epoch' do + expect(described_class.coerce_isolated_result(time)).to eq(float) + end + + it 'coerces fractional seconds since epoch into Time' do + expect(described_class.coerce_isolated_input(float)).to eq(time) + end +end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index a40330d853f..e90e0aba0a4 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -90,11 +90,13 @@ def failure_message(constant_name, migration_helper, missing_words: [], addition let(:routes_not_starting_in_wildcard) { routes_without_format.select { |p| p !~ %r{^/[:*]} } } let(:top_level_words) do - words = routes_not_starting_in_wildcard.map do |route| - route.split('/')[1] - end.compact - - (words + ee_top_level_words + files_in_public + Array(API::API.prefix.to_s)).uniq + routes_not_starting_in_wildcard + .map { |route| route.split('/')[1] } + .concat(ee_top_level_words) + .concat(files_in_public) + .concat(Array(API::API.prefix.to_s)) + .compact + .uniq end let(:ee_top_level_words) do diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb new file mode 100644 index 00000000000..5bb2cf9dd9e --- /dev/null +++ b/spec/support/helpers/graphql_helpers.rb @@ -0,0 +1,29 @@ +module GraphqlHelpers + # Run a loader's named resolver + def resolve(kls, name, obj: nil, args: {}, ctx: {}) + kls[name].call(obj, args, ctx) + end + + # Runs a block inside a GraphQL::Batch wrapper + def batch(max_queries: nil, &blk) + wrapper = proc do + GraphQL::Batch.batch do + result = yield + + if result.is_a?(Array) + Promise.all(result) + else + result + end + end + end + + if max_queries + result = nil + expect { result = wrapper.call }.not_to exceed_query_limit(max_queries) + result + else + wrapper.call + end + end +end diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb new file mode 100644 index 00000000000..c0ed16ecaba --- /dev/null +++ b/spec/support/matchers/graphql_matchers.rb @@ -0,0 +1,31 @@ +RSpec::Matchers.define :require_graphql_authorizations do |*expected| + match do |field| + authorizations = field.metadata[:authorize] + + expect(authorizations).to contain_exactly(*expected) + end +end + +RSpec::Matchers.define :have_graphql_fields do |*expected| + match do |kls| + expect(kls.fields.keys).to contain_exactly(*expected.map(&:to_s)) + end +end + +RSpec::Matchers.define :have_graphql_arguments do |*expected| + match do |field| + expect(field.arguments.keys).to contain_exactly(*expected.map(&:to_s)) + end +end + +RSpec::Matchers.define :have_graphql_type do |expected| + match do |field| + expect(field.type).to eq(expected) + end +end + +RSpec::Matchers.define :have_graphql_resolver do |expected| + match do |field| + expect(field.resolve_proc).to eq(expected) + end +end -- GitLab