From 9c6c17cbcdb8bf8185fc1b873dcfd08f723e4df5 Mon Sep 17 00:00:00 2001
From: Nick Thomas <nick@gitlab.com>
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 0582bfe8d70ce..2639a33f3639b 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 90fa659fe78ab..134b726c9eb59 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 2daaa3b516e19..68dd0fba256cb 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 0000000000000..ef258bf07cb72
--- /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 0000000000000..7392bf6f503cc
--- /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 0000000000000..c32c4daa91af7
--- /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 0000000000000..f99b487ce5d70
--- /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 0000000000000..e89031da0c294
--- /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 0000000000000..e69de29bb2d1d
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
new file mode 100644
index 0000000000000..9b12f6f2bf315
--- /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 0000000000000..c5061f102392a
--- /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 0000000000000..bfefc59489632
--- /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 0000000000000..029bbd098ad05
--- /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 0000000000000..fb717eb3dc7cd
--- /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 6616b85129eaf..55a8c9dac9bc6 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 ce7a7c889009e..54ce6d01df032 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 0000000000000..1df130e519af7
--- /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 0000000000000..d6689dbc3c673
--- /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 0000000000000..3582f29786635
--- /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 0000000000000..2a47323955091
--- /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 0000000000000..8a0c1f0791a43
--- /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 0000000000000..17d9395504cf5
--- /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 0000000000000..087655cc67da1
--- /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 a40330d853f10..e90e0aba0a486 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 0000000000000..5bb2cf9dd9e53
--- /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 0000000000000..c0ed16ecaba7a
--- /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