Add a minimal GraphQL API
This commit is contained in:
parent
67dc43db2f
commit
9c6c17cbcd
|
|
@ -43,6 +43,7 @@ Naming/FileName:
|
|||
- 'config/**/*'
|
||||
- 'lib/generators/**/*'
|
||||
- 'ee/lib/generators/**/*'
|
||||
- 'app/graphql/**/*'
|
||||
IgnoreExecutableScripts: true
|
||||
AllowedAcronyms:
|
||||
- EE
|
||||
|
|
|
|||
6
Gemfile
6
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'
|
||||
|
||||
|
|
|
|||
17
Gemfile.lock
17
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Types::MutationType = GraphQL::ObjectType.define do
|
||||
name "Mutation"
|
||||
|
||||
# TODO: Add Mutations as fields
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => '/'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -90,11 +90,13 @@ describe Gitlab::PathRegex do
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue