Commit 9b65d4bb authored by Bob Van Landuyt's avatar Bob Van Landuyt

Initial setup GraphQL using graphql-ruby 1.8

- All definitions have been replaced by classes:
  http://graphql-ruby.org/schema/class_based_api.html
- Authorization & Presentation have been refactored to work in the
  class based system
- Loaders have been replaced by resolvers
- Times are now coersed as ISO 8601
parent c443133e
...@@ -43,7 +43,6 @@ Naming/FileName: ...@@ -43,7 +43,6 @@ Naming/FileName:
- 'config/**/*' - 'config/**/*'
- 'lib/generators/**/*' - 'lib/generators/**/*'
- 'ee/lib/generators/**/*' - 'ee/lib/generators/**/*'
- 'app/graphql/**/*'
IgnoreExecutableScripts: true IgnoreExecutableScripts: true
AllowedAcronyms: AllowedAcronyms:
- EE - EE
......
...@@ -94,8 +94,7 @@ gem 'grape-entity', '~> 0.7.1' ...@@ -94,8 +94,7 @@ gem 'grape-entity', '~> 0.7.1'
gem 'rack-cors', '~> 1.0.0', require: 'rack/cors' gem 'rack-cors', '~> 1.0.0', require: 'rack/cors'
# GraphQL API # GraphQL API
gem 'graphql', '~> 1.7.14' gem 'graphql', '~> 1.8.0'
gem 'graphql-preload', '~> 2.0.0'
gem 'graphiql-rails', '~> 1.4.10' gem 'graphiql-rails', '~> 1.4.10'
# Disable strong_params so that Mash does not respond to :permitted? # Disable strong_params so that Mash does not respond to :permitted?
......
...@@ -368,15 +368,7 @@ GEM ...@@ -368,15 +368,7 @@ GEM
graphiql-rails (1.4.10) graphiql-rails (1.4.10)
railties railties
sprockets-rails sprockets-rails
graphql (1.7.14) graphql (1.8.1)
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) grpc (1.11.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
googleapis-common-protos-types (~> 1.0.0) googleapis-common-protos-types (~> 1.0.0)
...@@ -639,7 +631,6 @@ GEM ...@@ -639,7 +631,6 @@ GEM
unparser unparser
procto (0.0.3) procto (0.0.3)
prometheus-client-mmap (0.9.3) prometheus-client-mmap (0.9.3)
promise.rb (0.7.4)
pry (0.10.4) pry (0.10.4)
coderay (~> 1.1.0) coderay (~> 1.1.0)
method_source (~> 0.8.1) method_source (~> 0.8.1)
...@@ -1067,8 +1058,7 @@ DEPENDENCIES ...@@ -1067,8 +1058,7 @@ DEPENDENCIES
grape-path-helpers (~> 1.0) grape-path-helpers (~> 1.0)
grape_logging (~> 1.7) grape_logging (~> 1.7)
graphiql-rails (~> 1.4.10) graphiql-rails (~> 1.4.10)
graphql (~> 1.7.14) graphql (~> 1.8.0)
graphql-preload (~> 2.0.0)
grpc (~> 1.11.0) grpc (~> 1.11.0)
haml_lint (~> 0.26.0) haml_lint (~> 0.26.0)
hamlit (~> 2.6.1) hamlit (~> 2.6.1)
......
module Functions
class BaseFunction < GraphQL::Function
end
end
module Functions
class Echo < BaseFunction
argument :text, GraphQL::STRING_TYPE
description "Testing endpoint to validate the API with"
def call(obj, args, ctx)
username = ctx[:current_user]&.username
"#{username.inspect} says: #{args[:text]}"
end
end
end
Gitlab::Graphql::Authorize.register! class GitlabSchema < GraphQL::Schema
Gitlab::Graphql::Present.register!
GitlabSchema = GraphQL::Schema.define do
use BatchLoader::GraphQL use BatchLoader::GraphQL
use Gitlab::Graphql::Authorize
enable_preloading use Gitlab::Graphql::Present
enable_authorization
enable_presenting
query(Types::QueryType) query(Types::QueryType)
# mutation(Types::MutationType)
end end
# Helper methods for all loaders
module Loaders::BaseLoader
extend ActiveSupport::Concern
class_methods do
# Convert a class method into a resolver proc. The method should follow the
# (obj, args, ctx) calling convention
def [](sym)
resolver = method(sym)
raise ArgumentError.new("#{self}.#{sym} is not a resolver") unless resolver.arity == 3
resolver
end
end
end
class Loaders::IidLoader
include Loaders::BaseLoader
class << self
def merge_request(obj, args, ctx)
iid = args[:iid]
project = Loaders::FullPathLoader.project_by_full_path(args[:project])
merge_request_by_project_and_iid(project, iid)
end
def merge_request_by_project_and_iid(project_loader, iid)
project_id = project_loader.__sync&.id
# IIDs are represented as the GraphQL `id` type, which is a string
BatchLoader.for(iid.to_s).batch(key: "merge_request:target_project:#{project_id}:iid") do |iids, loader|
if project_id
results = MergeRequest.where(target_project_id: project_id, iid: iids)
results.each { |mr| loader.call(mr.iid.to_s, mr) }
end
end
end
end
end
module Resolvers
class BaseResolver < GraphQL::Schema::Resolver
end
end
module Loaders::FullPathLoader module Resolvers
include Loaders::BaseLoader module FullPathResolver
extend ActiveSupport::Concern
class << self prepended do
def project(obj, args, ctx) argument :full_path, GraphQL::ID_TYPE,
project_by_full_path(args[:full_path]) required: true,
end description: 'The full path of the project or namespace, e.g., "gitlab-org/gitlab-ce"'
def project_by_full_path(full_path)
model_by_full_path(Project, full_path)
end end
def model_by_full_path(model, full_path) def model_by_full_path(model, full_path)
......
module Resolvers
class MergeRequestResolver < BaseResolver
prepend FullPathResolver
type Types::ProjectType, null: true
argument :iid, GraphQL::ID_TYPE,
required: true,
description: 'The IID of the merge request, e.g., "1"'
def resolve(full_path:, iid:)
project = model_by_full_path(Project, full_path)
return unless project.present?
BatchLoader.for(iid.to_s).batch(key: project.id) do |iids, loader|
results = project.merge_requests.where(iid: iids)
results.each { |mr| loader.call(mr.iid.to_s, mr) }
end
end
end
end
module Resolvers
class ProjectResolver < BaseResolver
prepend FullPathResolver
type Types::ProjectType, null: true
def resolve(full_path:)
model_by_full_path(Project, full_path)
end
end
end
module Types
class BaseEnum < GraphQL::Schema::Enum
end
end
module Types
class BaseField < GraphQL::Schema::Field
prepend Gitlab::Graphql::Authorize
end
end
module Types
class BaseInputObject < GraphQL::Schema::InputObject
end
end
module Types
module BaseInterface
include GraphQL::Schema::Interface
end
end
module Types
class BaseObject < GraphQL::Schema::Object
prepend Gitlab::Graphql::Present
field_class Types::BaseField
end
end
module Types
class BaseScalar < GraphQL::Schema::Scalar
end
end
module Types
class BaseUnion < GraphQL::Schema::Union
end
end
Types::MergeRequestType = GraphQL::ObjectType.define do module Types
present_using MergeRequestPresenter class MergeRequestType < BaseObject
present_using MergeRequestPresenter
name 'MergeRequest' graphql_name 'MergeRequest'
field :id, !types.ID field :id, GraphQL::ID_TYPE, null: false
field :iid, !types.ID field :iid, GraphQL::ID_TYPE, null: false
field :title, !types.String field :title, GraphQL::STRING_TYPE, null: false
field :description, types.String field :description, GraphQL::STRING_TYPE, null: true
field :state, types.String field :state, GraphQL::STRING_TYPE, null: true
field :created_at, !Types::TimeType field :created_at, Types::TimeType, null: false
field :updated_at, !Types::TimeType field :updated_at, Types::TimeType, null: false
field :source_project, Types::ProjectType field :source_project, Types::ProjectType, null: true
field :target_project, !Types::ProjectType field :target_project, Types::ProjectType, null: false
# Alias for target_project # Alias for target_project
field :project, !Types::ProjectType field :project, Types::ProjectType, null: false
field :project_id, !types.Int, property: :target_project_id field :project_id, GraphQL::INT_TYPE, null: false, method: :target_project_id
field :source_project_id, types.Int field :source_project_id, GraphQL::INT_TYPE, null: true
field :target_project_id, !types.Int field :target_project_id, GraphQL::INT_TYPE, null: false
field :source_branch, !types.String field :source_branch, GraphQL::STRING_TYPE, null: false
field :target_branch, !types.String field :target_branch, GraphQL::STRING_TYPE, null: false
field :work_in_progress, types.Boolean, property: :work_in_progress? field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false
field :merge_when_pipeline_succeeds, types.Boolean field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true
field :sha, types.String, property: :diff_head_sha field :diff_head_sha, GraphQL::STRING_TYPE, null: true
field :merge_commit_sha, types.String field :merge_commit_sha, GraphQL::STRING_TYPE, null: true
field :user_notes_count, types.Int field :user_notes_count, GraphQL::INT_TYPE, null: true
field :should_remove_source_branch, types.Boolean, property: :should_remove_source_branch? field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true
field :force_remove_source_branch, types.Boolean, property: :force_remove_source_branch? field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true
field :merge_status, types.String field :merge_status, GraphQL::STRING_TYPE, null: true
field :in_progress_merge_commit_sha, types.String field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true
field :merge_error, types.String field :merge_error, GraphQL::STRING_TYPE, null: true
field :allow_maintainer_to_push, types.Boolean field :allow_collaboration, GraphQL::BOOLEAN_TYPE, null: true
field :should_be_rebased, types.Boolean, property: :should_be_rebased? field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false
field :rebase_commit_sha, types.String field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true
field :rebase_in_progress, types.Boolean, property: :rebase_in_progress? field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false
field :diff_head_sha, types.String field :diff_head_sha, GraphQL::STRING_TYPE, null: true
field :merge_commit_message, types.String field :merge_commit_message, GraphQL::STRING_TYPE, null: true
field :merge_ongoing, types.Boolean, property: :merge_ongoing? field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false
field :work_in_progress, types.Boolean, property: :work_in_progress? field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false
field :source_branch_exists, types.Boolean, property: :source_branch_exists? field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true
field :mergeable_discussions_state, types.Boolean field :web_url, GraphQL::STRING_TYPE, null: true
field :web_url, types.String, property: :web_url field :upvotes, GraphQL::INT_TYPE, null: false
field :upvotes, types.Int field :downvotes, GraphQL::INT_TYPE, null: false
field :downvotes, types.Int field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false
field :subscribed, types.Boolean, property: :subscribed? end
end end
Types::MutationType = GraphQL::ObjectType.define do module Types
name "Mutation" class MutationType < BaseObject
graphql_name "Mutation"
# TODO: Add Mutations as fields # TODO: Add Mutations as fields
end
end end
Types::ProjectType = GraphQL::ObjectType.define do module Types
name 'Project' class ProjectType < BaseObject
graphql_name 'Project'
field :id, !types.ID field :id, GraphQL::ID_TYPE, null: false
field :full_path, !types.ID field :full_path, GraphQL::ID_TYPE, null: false
field :path, !types.String field :path, GraphQL::STRING_TYPE, null: false
field :name_with_namespace, !types.String field :name_with_namespace, GraphQL::STRING_TYPE, null: false
field :name, !types.String field :name, GraphQL::STRING_TYPE, null: false
field :description, types.String field :description, GraphQL::STRING_TYPE, null: true
field :default_branch, types.String field :default_branch, GraphQL::STRING_TYPE, null: true
field :tag_list, types.String field :tag_list, GraphQL::STRING_TYPE, null: true
field :ssh_url_to_repo, types.String field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true
field :http_url_to_repo, types.String field :http_url_to_repo, GraphQL::STRING_TYPE, null: true
field :web_url, types.String field :web_url, GraphQL::STRING_TYPE, null: true
field :star_count, !types.Int field :star_count, GraphQL::INT_TYPE, null: false
field :forks_count, !types.Int field :forks_count, GraphQL::INT_TYPE, null: false
field :created_at, Types::TimeType field :created_at, Types::TimeType, null: true
field :last_activity_at, Types::TimeType field :last_activity_at, Types::TimeType, null: true
field :archived, types.Boolean field :archived, GraphQL::BOOLEAN_TYPE, null: true
field :visibility, types.String field :visibility, GraphQL::STRING_TYPE, null: true
field :container_registry_enabled, types.Boolean field :container_registry_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :shared_runners_enabled, types.Boolean field :shared_runners_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :lfs_enabled, types.Boolean field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :ff_only_enabled, types.Boolean, property: :merge_requests_ff_only_enabled field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :avatar_url, types.String do field :avatar_url, GraphQL::STRING_TYPE, null: true, resolve: -> (project, args, ctx) do
resolve ->(project, args, ctx) { project.avatar_url(only_path: false) } project.avatar_url(only_path: false)
end end
%i[issues merge_requests wiki snippets].each do |feature| %i[issues merge_requests wiki snippets].each do |feature|
field "#{feature}_enabled", types.Boolean do field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do
resolve ->(project, args, ctx) { project.feature_available?(feature, ctx[:current_user]) } project.feature_available?(feature, ctx[:current_user])
end
end end
end
field :jobs_enabled, types.Boolean do field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do
resolve ->(project, args, ctx) { project.feature_available?(:builds, ctx[:current_user]) } project.feature_available?(:builds, ctx[:current_user])
end end
field :public_jobs, types.Boolean, property: :public_builds field :public_jobs, GraphQL::BOOLEAN_TYPE, method: :public_builds, null: true
field :open_issues_count, types.Int do field :open_issues_count, GraphQL::INT_TYPE, null: true, resolve: -> (project, args, ctx) do
resolve ->(project, args, ctx) { project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) } project.open_issues_count if project.feature_available?(:issues, ctx[:current_user])
end end
field :import_status, types.String field :import_status, GraphQL::STRING_TYPE, null: true
field :ci_config_path, types.String field :ci_config_path, GraphQL::STRING_TYPE, null: true
field :only_allow_merge_if_pipeline_succeeds, types.Boolean field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true
field :request_access_enabled, types.Boolean field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :only_allow_merge_if_all_discussions_are_resolved, types.Boolean field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true
field :printing_merge_request_link_enabled, types.Boolean field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true
end
end end
Types::QueryType = GraphQL::ObjectType.define do module Types
name 'Query' class QueryType < BaseObject
graphql_name 'Query'
field :project, Types::ProjectType do
argument :full_path, !types.ID do field :project, Types::ProjectType,
description 'The full path of the project, e.g., "gitlab-org/gitlab-ce"' null: true,
end resolver: Resolvers::ProjectResolver,
description: "Find a project" do
authorize :read_project 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 end
argument :iid, !types.ID do field :merge_request, Types::MergeRequestType,
description 'The IID of the merge request, e.g., "1"' null: true,
resolver: Resolvers::MergeRequestResolver,
description: "Find a merge request" do
authorize :read_merge_request
end end
authorize :read_merge_request field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new
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
end end
# Taken from http://www.rubydoc.info/github/rmosolgo/graphql-ruby/GraphQL/ScalarType module Types
Types::TimeType = GraphQL::ScalarType.define do class TimeType < BaseScalar
name 'Time' graphql_name 'Time'
description 'Time since epoch in fractional seconds' description 'Time represented in ISO 8601'
coerce_input ->(value, ctx) { Time.at(Float(value)) } def self.coerce_input(value, ctx)
coerce_result ->(value, ctx) { value.to_f } Time.parse(value)
end
def self.coerce_result(value, ctx)
value.iso8601
end
end
end end
post '/api/graphql', to: 'graphql#execute' constraints(::Constraints::FeatureConstrainer.new(:graphql)) do
mount GraphiQL::Rails::Engine, at: '/api/graphiql', graphql_path: '/api/graphql' post '/api/graphql', to: 'graphql#execute'
mount GraphiQL::Rails::Engine, at: '/-/graphql-explorer', graphql_path: '/api/graphql'
end
API::API.logger Rails.logger API::API.logger Rails.logger
mount API::API => '/' mount API::API => '/'
...@@ -2,33 +2,41 @@ ...@@ -2,33 +2,41 @@
> [Introduced][ce-19008] in GitLab 11.0. > [Introduced][ce-19008] in GitLab 11.0.
## Enabling the GraphQL feature [GraphQL](https://graphql.org/) is a query language for APIs that
allows clients to request exactly the data they need, making it
possible to get all required data in a limited number of requests.
The GraphQL API itself is currently in Beta, and therefore hidden behind a The GraphQL data (fields) can be described in the form of types,
feature flag. To enable it on your selfhosted instance, run allowing clients to use [clientside GraphQL
`Feature.enable(:graphql)`. libraries](https://graphql.org/code/#graphql-clients) to consume the
API and avoid manual parsing.
Start the console by running Since there's no fixed endpoints and datamodel, new abilities can be
added to the API without creating breaking changes. This allows us to
have a versionless API as described in [the GraphQL
documentation](https://graphql.org/learn/best-practices/#versioning).
```bash ## Enabling the GraphQL feature
sudo gitlab-rails console
``` The GraphQL API itself is currently in Alpha, and therefore hidden behind a
feature flag. You can enable the feature using the [features api][features-api] on a self-hosted instance.
Then enable the feature by running For example:
```ruby ```shell
Feature.enable(:graphql) curl --data "value=100" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features/graphql
``` ```
## Available queries ## Available queries
A first iteration of a GraphQL API inlcudes only 2 queries: `project` and A first iteration of a GraphQL API includes only 2 queries: `project` and
`merge_request` and only returns scalar fields, or fields of the type `Project` `merge_request` and only returns scalar fields, or fields of the type `Project`
or `MergeRequest`. or `MergeRequest`.
## GraphiQL ## GraphiQL
The API can be explored by using the GraphiQL IDE, it is available on your The API can be explored by using the GraphiQL IDE, it is available on your
instance on `gitlab.example.com/api/graphiql`. instance on `gitlab.example.com/-/graphql-explorer`.
[ce-19008]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19008 [ce-19008]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19008
[features-api]: ../features.md
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
## Authentication ## Authentication
Authentication happens through the `GrapqlController`, right now this Authentication happens through the `GraphqlController`, right now this
uses the same authentication as the rails application. So the session uses the same authentication as the Rails application. So the session
can be shared. can be shared.
It is also possible to add a `private_token` to the querystring, or It is also possible to add a `private_token` to the querystring, or
...@@ -11,27 +11,25 @@ add a `HTTP_PRIVATE_TOKEN` header. ...@@ -11,27 +11,25 @@ add a `HTTP_PRIVATE_TOKEN` header.
### Authorization ### Authorization
Fields can be authorized using the same abilities used in the rails Fields can be authorized using the same abilities used in the Rails
app. This can be done using the `authorize` helper: app. This can be done using the `authorize` helper:
```ruby ```ruby
Types::QueryType = GraphQL::ObjectType.define do module Types
name 'Query' class QueryType < BaseObject
graphql_name 'Query'
field :project, Types::ProjectType do field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver do
argument :full_path, !types.ID do authorize :read_project
description 'The full path of the project, e.g., "gitlab-org/gitlab-ce"'
end end
authorize :read_project
resolve Loaders::FullPathLoader[:project]
end end
end
``` ```
The object found by the resolve call is used for authorization. The object found by the resolve call is used for authorization.
This works for authorizing a single record, for authorizing
collections, we should only load what the currently authenticated user
is allowed to view. Preferably we use our existing finders for that.
## Types ## Types
...@@ -43,7 +41,7 @@ the definition as minimal as possible. Instead, consider moving any ...@@ -43,7 +41,7 @@ the definition as minimal as possible. Instead, consider moving any
logic into a presenter: logic into a presenter:
```ruby ```ruby
Types::MergeRequestType = GraphQL::ObjectType.define do class Types::MergeRequestType < BaseObject
present_using MergeRequestPresenter present_using MergeRequestPresenter
name 'MergeRequest' name 'MergeRequest'
...@@ -56,11 +54,28 @@ a new presenter specifically for GraphQL. ...@@ -56,11 +54,28 @@ a new presenter specifically for GraphQL.
The presenter is initialized using the object resolved by a field, and The presenter is initialized using the object resolved by a field, and
the context. the context.
## Resolvers
To find objects to display in a field, we can add resolvers to
`app/graphql/resolvers`.
Arguments can be defined within the resolver, those arguments will be
made available to the fields using the resolver.
We already have a `FullPathLoader` that can be included in other
resolvers to quickly find Projects and Namespaces which will have a
lot of dependant objects.
To limit the amount of queries performed, we can use `BatchLoader`.
## Testing ## Testing
_full stack_ tests for a graphql query or mutation live in _full stack_ tests for a graphql query or mutation live in
`spec/requests/graphql`. `spec/requests/api/graphql`.
When adding a query, the `a working graphql query` shared example can When adding a query, the `a working graphql query` shared example can
be used to test the query, it expects a valid `query` to be available be used to test if the query renders valid results.
in the spec.
Using the `GraphqlHelpers#all_graphql_fields_for`-helper, a query
including all available fields can be constructed. This makes it easy
to add a test rendering all possible fields for a query.
module Constraints
class FeatureConstrainer
attr_reader :feature
def initialize(feature)
@feature = feature
end
def matches?(_request)
Feature.enabled?(feature)
end
end
end
...@@ -2,55 +2,19 @@ module Gitlab ...@@ -2,55 +2,19 @@ module Gitlab
module Graphql module Graphql
# Allow fields to declare permissions their objects must have. The field # Allow fields to declare permissions their objects must have. The field
# will be set to nil unless all required permissions are present. # will be set to nil unless all required permissions are present.
class Authorize module Authorize
SETUP_PROC = -> (type, *args) do extend ActiveSupport::Concern
type.metadata[:authorize] ||= []
type.metadata[:authorize].concat(args)
end
INSTRUMENT_PROC = -> (schema) do
schema.instrument(:field, new)
end
def self.register! def self.use(schema_definition)
GraphQL::Schema.accepts_definitions(enable_authorization: INSTRUMENT_PROC) schema_definition.instrument(:field, Instrumentation.new)
GraphQL::Field.accepts_definitions(authorize: SETUP_PROC)
end end
# Replace the resolver for the field with one that will only return the def required_permissions
# resolved object if the permissions check is successful. @required_permissions ||= []
#
# 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 end
private def authorize(*permissions)
required_permissions.concat(permissions)
def build_checker(current_user, abilities)
proc do |obj|
# Load the elements if they weren't loaded by BatchLoader yet
obj = obj.sync if obj.respond_to?(:sync)
obj if abilities.all? { |ability| Ability.allowed?(current_user, ability, obj) }
end
end end
end end
end end
......
module Gitlab
module Graphql
module Authorize
class Instrumentation
# 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)
field_definition = field.metadata[:type_class]
return field unless field_definition.respond_to?(:required_permissions)
return field if field_definition.required_permissions.empty?
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_definition.required_permissions)
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|
# Load the elements if they weren't loaded by BatchLoader yet
obj = obj.sync if obj.respond_to?(:sync)
obj if abilities.all? { |ability| Ability.allowed?(current_user, ability, obj) }
end
end
end
end
end
end
module Gitlab module Gitlab
module Graphql module Graphql
class Present module Present
PRESENT_USING = -> (type, presenter_class, *args) do extend ActiveSupport::Concern
type.metadata[:presenter_class] = presenter_class prepended do
end def self.present_using(kls)
@presenter_class = kls
INSTRUMENT_PROC = -> (schema) do
schema.instrument(:field, new)
end
def self.register!
GraphQL::Schema.accepts_definitions(enable_presenting: INSTRUMENT_PROC)
GraphQL::ObjectType.accepts_definitions(present_using: PRESENT_USING)
end
def instrument(type, field)
return field unless type.metadata[:presenter_class]
old_resolver = field.resolve_proc
resolve_with_presenter = -> (obj, args, context) do
presenter = type.metadata[:presenter_class].new(obj, **context.to_h)
old_resolver.call(presenter, args, context)
end end
field.redefine do def self.presenter_class
resolve(resolve_with_presenter) @presenter_class
end end
end end
def self.use(schema_definition)
schema_definition.instrument(:field, Instrumentation.new)
end
end end
end end
end end
module Gitlab
module Graphql
module Present
class Instrumentation
def instrument(type, field)
presented_in = field.metadata[:type_class].owner
return field unless presented_in.respond_to?(:presenter_class)
return field unless presented_in.presenter_class
old_resolver = field.resolve_proc
resolve_with_presenter = -> (presented_type, args, context) do
object = presented_type.object
presenter = presented_in.presenter_class.new(object, **context.to_h)
old_resolver.call(presenter, args, context)
end
field.redefine do
resolve(resolve_with_presenter)
end
end
end
end
end
end
...@@ -6,26 +6,25 @@ describe GitlabSchema do ...@@ -6,26 +6,25 @@ describe GitlabSchema do
end end
it 'enables the preload instrumenter' do it 'enables the preload instrumenter' do
expect(field_instrumenters).to include(instance_of(::GraphQL::Preload::Instrument)) expect(field_instrumenters).to include(BatchLoader::GraphQL)
end end
it 'enables the authorization instrumenter' do it 'enables the authorization instrumenter' do
expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Authorize)) expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Authorize::Instrumentation))
end end
it 'enables using presenters' do it 'enables using presenters' do
expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Present)) expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Present::Instrumentation))
end end
it 'has the base mutation' do it 'has the base mutation' do
pending <<~REASON pending('Adding an empty mutation breaks the documentation explorer')
Having empty mutations breaks the automatic documentation in Graphiql, so removed for now."
REASON expect(described_class.mutation).to eq(::Types::MutationType.to_graphql)
expect(described_class.mutation).to eq(::Types::MutationType)
end end
it 'has the base query' do it 'has the base query' do
expect(described_class.query).to eq(::Types::QueryType) expect(described_class.query).to eq(::Types::QueryType.to_graphql)
end end
def field_instrumenters def field_instrumenters
......
require 'spec_helper' require 'spec_helper'
describe Loaders::IidLoader do describe Resolvers::MergeRequestResolver do
include GraphqlHelpers include GraphqlHelpers
set(:project) { create(:project, :repository) } set(:project) { create(:project, :repository) }
...@@ -17,7 +17,7 @@ describe Loaders::IidLoader do ...@@ -17,7 +17,7 @@ describe Loaders::IidLoader do
let(:other_full_path) { other_project.full_path } let(:other_full_path) { other_project.full_path }
let(:other_iid) { other_merge_request.iid } let(:other_iid) { other_merge_request.iid }
describe '.merge_request' do describe '#resolve' do
it 'batch-resolves merge requests by target project full path and IID' do it 'batch-resolves merge requests by target project full path and IID' do
path = full_path # avoid database query path = full_path # avoid database query
...@@ -53,6 +53,6 @@ describe Loaders::IidLoader do ...@@ -53,6 +53,6 @@ describe Loaders::IidLoader do
end end
def resolve_mr(full_path, iid) def resolve_mr(full_path, iid)
resolve(described_class, :merge_request, args: { project: full_path, iid: iid }) resolve(described_class, args: { full_path: full_path, iid: iid })
end end
end end
require 'spec_helper' require 'spec_helper'
describe Loaders::FullPathLoader do describe Resolvers::ProjectResolver do
include GraphqlHelpers include GraphqlHelpers
set(:project1) { create(:project) } set(:project1) { create(:project) }
...@@ -8,7 +8,7 @@ describe Loaders::FullPathLoader do ...@@ -8,7 +8,7 @@ describe Loaders::FullPathLoader do
set(:other_project) { create(:project) } set(:other_project) { create(:project) }
describe '.project' do describe '#resolve' do
it 'batch-resolves projects by full path' do it 'batch-resolves projects by full path' do
paths = [project1.full_path, project2.full_path] paths = [project1.full_path, project2.full_path]
...@@ -27,6 +27,6 @@ describe Loaders::FullPathLoader do ...@@ -27,6 +27,6 @@ describe Loaders::FullPathLoader do
end end
def resolve_project(full_path) def resolve_project(full_path)
resolve(described_class, :project, args: { full_path: full_path }) resolve(described_class, args: { full_path: full_path })
end end
end end
require 'spec_helper'
describe GitlabSchema.types['Project'] do
it { expect(described_class.graphql_name).to eq('Project') }
end
...@@ -2,7 +2,7 @@ require 'spec_helper' ...@@ -2,7 +2,7 @@ require 'spec_helper'
describe GitlabSchema.types['Query'] do describe GitlabSchema.types['Query'] do
it 'is called Query' do it 'is called Query' do
expect(described_class.name).to eq('Query') expect(described_class.graphql_name).to eq('Query')
end end
it { is_expected.to have_graphql_fields(:project, :merge_request, :echo) } it { is_expected.to have_graphql_fields(:project, :merge_request, :echo) }
...@@ -13,7 +13,7 @@ describe GitlabSchema.types['Query'] do ...@@ -13,7 +13,7 @@ describe GitlabSchema.types['Query'] do
it 'finds projects by full path' do it 'finds projects by full path' do
is_expected.to have_graphql_arguments(:full_path) is_expected.to have_graphql_arguments(:full_path)
is_expected.to have_graphql_type(Types::ProjectType) is_expected.to have_graphql_type(Types::ProjectType)
is_expected.to have_graphql_resolver(Loaders::FullPathLoader[:project]) is_expected.to have_graphql_resolver(Resolvers::ProjectResolver)
end end
it 'authorizes with read_project' do it 'authorizes with read_project' do
...@@ -22,12 +22,12 @@ describe GitlabSchema.types['Query'] do ...@@ -22,12 +22,12 @@ describe GitlabSchema.types['Query'] do
end end
describe 'merge_request field' do describe 'merge_request field' do
subject { described_class.fields['merge_request'] } subject { described_class.fields['mergeRequest'] }
it 'finds MRs by project and IID' do it 'finds MRs by project and IID' do
is_expected.to have_graphql_arguments(:project, :iid) is_expected.to have_graphql_arguments(:full_path, :iid)
is_expected.to have_graphql_type(Types::MergeRequestType) is_expected.to have_graphql_type(Types::MergeRequestType)
is_expected.to have_graphql_resolver(Loaders::IidLoader[:merge_request]) is_expected.to have_graphql_resolver(Resolvers::MergeRequestResolver)
end end
it 'authorizes with read_merge_request' do it 'authorizes with read_merge_request' do
......
require 'spec_helper' require 'spec_helper'
describe GitlabSchema.types['Time'] do describe GitlabSchema.types['Time'] do
let(:float) { 1504630455.96215 } let(:iso) { "2018-06-04T15:23:50+02:00" }
let(:time) { Time.at(float) } let(:time) { Time.parse(iso) }
it { expect(described_class.name).to eq('Time') } it { expect(described_class.graphql_name).to eq('Time') }
it 'coerces Time into fractional seconds since epoch' do it 'coerces Time object into ISO 8601' do
expect(described_class.coerce_isolated_result(time)).to eq(float) expect(described_class.coerce_isolated_result(time)).to eq(iso)
end end
it 'coerces fractional seconds since epoch into Time' do it 'coerces an ISO-time into Time object' do
expect(described_class.coerce_isolated_input(float)).to eq(time) expect(described_class.coerce_isolated_input(iso)).to eq(time)
end end
end end
require 'spec_helper'
describe 'getting merge request information' do
include GraphqlHelpers
let(:project) { create(:project, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:current_user) { create(:user) }
let(:query) do
attributes = {
'fullPath' => merge_request.project.full_path,
'iid' => merge_request.iid
}
graphql_query_for('mergeRequest', attributes)
end
context 'when the user has access to the merge request' do
before do
project.add_developer(current_user)
post_graphql(query, current_user: current_user)
end
it 'returns the merge request' do
expect(graphql_data['mergeRequest']).not_to be_nil
end
# This is a field coming from the `MergeRequestPresenter`
it 'includes a web_url' do
expect(graphql_data['mergeRequest']['webUrl']).to be_present
end
it_behaves_like 'a working graphql query'
end
context 'when the user does not have access to the merge request' do
before do
post_graphql(query, current_user: current_user)
end
it 'returns an empty field' do
post_graphql(query, current_user: current_user)
expect(graphql_data['mergeRequest']).to be_nil
end
it_behaves_like 'a working graphql query'
end
end
require 'spec_helper'
describe 'getting project information' do
include GraphqlHelpers
let(:project) { create(:project, :repository) }
let(:current_user) { create(:user) }
let(:query) do
graphql_query_for('project', 'fullPath' => project.full_path)
end
context 'when the user has access to the project' do
before do
project.add_developer(current_user)
post_graphql(query, current_user: current_user)
end
it 'includes the project' do
expect(graphql_data['project']).not_to be_nil
end
it_behaves_like 'a working graphql query'
end
context 'when the user does not have access to the project' do
before do
post_graphql(query, current_user: current_user)
end
it 'returns an empty field' do
post_graphql(query, current_user: current_user)
expect(graphql_data['project']).to be_nil
end
it_behaves_like 'a working graphql query'
end
end
require 'spec_helper'
describe 'getting merge request information' do
include GraphqlHelpers
let(:project) { create(:project, :repository, :public) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:query) do
<<~QUERY
{
merge_request(project: "#{merge_request.project.full_path}", iid: "#{merge_request.iid}") {
#{all_graphql_fields_for(MergeRequest)}
}
}
QUERY
end
it_behaves_like 'a working graphql query' do
it 'renders a merge request with all fields' do
expect(response_data['merge_request']).not_to be_nil
end
end
end
require 'spec_helper'
describe 'getting project information' do
include GraphqlHelpers
let(:project) { create(:project, :repository, :public) }
let(:query) do
<<~QUERY
{
project(full_path: "#{project.full_path}") {
#{all_graphql_fields_for(Project)}
}
}
QUERY
end
it_behaves_like 'a working graphql query' do
it 'renders a project with all fields' do
expect(response_data['project']).not_to be_nil
end
end
end
require 'spec_helper'
describe 'api', 'routing' do
context 'when graphql is disabled' do
before do
stub_feature_flags(graphql: false)
end
it 'does not route to the GraphqlController' do
expect(get('/api/graphql')).not_to route_to('graphql#execute')
end
it 'does not expose graphiql' do
expect(get('/-/graphql-explorer')).not_to route_to('graphiql/rails/editors#show')
end
end
context 'when graphql is disabled' do
before do
stub_feature_flags(graphql: true)
end
it 'routes to the GraphqlController' do
expect(get('/api/graphql')).not_to route_to('graphql#execute')
end
it 'exposes graphiql' do
expect(get('/-/graphql-explorer')).not_to route_to('graphiql/rails/editors#show')
end
end
end
module GraphqlHelpers module GraphqlHelpers
# makes an underscored string look like a fieldname
# "merge_request" => "mergeRequest"
def self.fieldnamerize(underscored_field_name)
graphql_field_name = underscored_field_name.to_s.camelize
graphql_field_name[0] = graphql_field_name[0].downcase
graphql_field_name
end
# Run a loader's named resolver # Run a loader's named resolver
def resolve(kls, name, obj: nil, args: {}, ctx: {}) def resolve(resolver_class, obj: nil, args: {}, ctx: {})
kls[name].call(obj, args, ctx) resolver_class.new(object: obj, context: ctx).resolve(args)
end end
# Runs a block inside a BatchLoader::Executor wrapper # Runs a block inside a BatchLoader::Executor wrapper
...@@ -24,8 +33,20 @@ module GraphqlHelpers ...@@ -24,8 +33,20 @@ module GraphqlHelpers
end end
end end
def all_graphql_fields_for(klass) def graphql_query_for(name, attributes = {}, fields = nil)
type = GitlabSchema.types[klass.name] fields ||= all_graphql_fields_for(name.classify)
attributes = attributes_to_graphql(attributes)
<<~QUERY
{
#{name}(#{attributes}) {
#{fields}
}
}
QUERY
end
def all_graphql_fields_for(class_name)
type = GitlabSchema.types[class_name.to_s]
return "" unless type return "" unless type
type.fields.map do |name, field| type.fields.map do |name, field|
...@@ -37,8 +58,22 @@ module GraphqlHelpers ...@@ -37,8 +58,22 @@ module GraphqlHelpers
end.join("\n") end.join("\n")
end end
def post_graphql(query) def attributes_to_graphql(attributes)
post '/api/graphql', query: query attributes.map do |name, value|
"#{GraphqlHelpers.fieldnamerize(name.to_s)}: \"#{value}\""
end.join(", ")
end
def post_graphql(query, current_user: nil)
post api('/', current_user, version: 'graphql'), query: query
end
def graphql_data
json_response['data']
end
def graphql_errors
json_response['data']
end end
def scalar?(field) def scalar?(field)
......
RSpec::Matchers.define :require_graphql_authorizations do |*expected| RSpec::Matchers.define :require_graphql_authorizations do |*expected|
match do |field| match do |field|
authorizations = field.metadata[:authorize] field_definition = field.metadata[:type_class]
expect(field_definition).to respond_to(:required_permissions)
expect(authorizations).to contain_exactly(*expected) expect(field_definition.required_permissions).to contain_exactly(*expected)
end end
end end
RSpec::Matchers.define :have_graphql_fields do |*expected| RSpec::Matchers.define :have_graphql_fields do |*expected|
match do |kls| match do |kls|
expect(kls.fields.keys).to contain_exactly(*expected.map(&:to_s)) field_names = expected.map { |name| GraphqlHelpers.fieldnamerize(name) }
expect(kls.fields.keys).to contain_exactly(*field_names)
end end
end end
RSpec::Matchers.define :have_graphql_arguments do |*expected| RSpec::Matchers.define :have_graphql_arguments do |*expected|
include GraphqlHelpers
match do |field| match do |field|
expect(field.arguments.keys).to contain_exactly(*expected.map(&:to_s)) argument_names = expected.map { |name| GraphqlHelpers.fieldnamerize(name) }
expect(field.arguments.keys).to contain_exactly(*argument_names)
end end
end end
RSpec::Matchers.define :have_graphql_type do |expected| RSpec::Matchers.define :have_graphql_type do |expected|
match do |field| match do |field|
expect(field.type).to eq(expected) expect(field.type).to eq(expected.to_graphql)
end end
end end
RSpec::Matchers.define :have_graphql_resolver do |expected| RSpec::Matchers.define :have_graphql_resolver do |expected|
match do |field| match do |field|
expect(field.resolve_proc).to eq(expected) case expected
when Method
expect(field.metadata[:type_class].resolve_proc).to eq(expected)
else
expect(field.metadata[:type_class].resolver).to eq(expected)
end
end end
end end
...@@ -3,16 +3,9 @@ require 'spec_helper' ...@@ -3,16 +3,9 @@ require 'spec_helper'
shared_examples 'a working graphql query' do shared_examples 'a working graphql query' do
include GraphqlHelpers include GraphqlHelpers
let(:parsed_response) { JSON.parse(response.body) }
let(:response_data) { parsed_response['data'] }
before do
post_graphql(query)
end
it 'is returns a successfull response', :aggregate_failures do it 'is returns a successfull response', :aggregate_failures do
expect(response).to be_success expect(response).to be_success
expect(parsed_response['errors']).to be_nil expect(graphql_errors['errors']).to be_nil
expect(response_data).not_to be_empty expect(json_response.keys).to include('data')
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment