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:
- 'config/**/*'
- 'lib/generators/**/*'
- 'ee/lib/generators/**/*'
- 'app/graphql/**/*'
IgnoreExecutableScripts: true
AllowedAcronyms:
- EE
......
......@@ -94,8 +94,7 @@ gem 'grape-entity', '~> 0.7.1'
gem 'rack-cors', '~> 1.0.0', require: 'rack/cors'
# GraphQL API
gem 'graphql', '~> 1.7.14'
gem 'graphql-preload', '~> 2.0.0'
gem 'graphql', '~> 1.8.0'
gem 'graphiql-rails', '~> 1.4.10'
# Disable strong_params so that Mash does not respond to :permitted?
......
......@@ -368,15 +368,7 @@ GEM
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)
graphql (1.8.1)
grpc (1.11.0)
google-protobuf (~> 3.1)
googleapis-common-protos-types (~> 1.0.0)
......@@ -639,7 +631,6 @@ 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)
......@@ -1067,8 +1058,7 @@ DEPENDENCIES
grape-path-helpers (~> 1.0)
grape_logging (~> 1.7)
graphiql-rails (~> 1.4.10)
graphql (~> 1.7.14)
graphql-preload (~> 2.0.0)
graphql (~> 1.8.0)
grpc (~> 1.11.0)
haml_lint (~> 0.26.0)
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!
Gitlab::Graphql::Present.register!
GitlabSchema = GraphQL::Schema.define do
class GitlabSchema < GraphQL::Schema
use BatchLoader::GraphQL
enable_preloading
enable_authorization
enable_presenting
use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Present
query(Types::QueryType)
# mutation(Types::MutationType)
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
include Loaders::BaseLoader
module Resolvers
module FullPathResolver
extend ActiveSupport::Concern
class << self
def project(obj, args, ctx)
project_by_full_path(args[:full_path])
end
def project_by_full_path(full_path)
model_by_full_path(Project, full_path)
prepended do
argument :full_path, GraphQL::ID_TYPE,
required: true,
description: 'The full path of the project or namespace, e.g., "gitlab-org/gitlab-ce"'
end
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
present_using MergeRequestPresenter
module Types
class MergeRequestType < BaseObject
present_using MergeRequestPresenter
name 'MergeRequest'
graphql_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 :project_id, !types.Int, property: :target_project_id
field :source_project_id, types.Int
field :target_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 :in_progress_merge_commit_sha, types.String
field :merge_error, types.String
field :allow_maintainer_to_push, types.Boolean
field :should_be_rebased, types.Boolean, property: :should_be_rebased?
field :rebase_commit_sha, types.String
field :rebase_in_progress, types.Boolean, property: :rebase_in_progress?
field :diff_head_sha, types.String
field :merge_commit_message, types.String
field :merge_ongoing, types.Boolean, property: :merge_ongoing?
field :work_in_progress, types.Boolean, property: :work_in_progress?
field :source_branch_exists, types.Boolean, property: :source_branch_exists?
field :mergeable_discussions_state, types.Boolean
field :web_url, types.String, property: :web_url
field :upvotes, types.Int
field :downvotes, types.Int
field :subscribed, types.Boolean, property: :subscribed?
field :id, GraphQL::ID_TYPE, null: false
field :iid, GraphQL::ID_TYPE, null: false
field :title, GraphQL::STRING_TYPE, null: false
field :description, GraphQL::STRING_TYPE, null: true
field :state, GraphQL::STRING_TYPE, null: true
field :created_at, Types::TimeType, null: false
field :updated_at, Types::TimeType, null: false
field :source_project, Types::ProjectType, null: true
field :target_project, Types::ProjectType, null: false
# Alias for target_project
field :project, Types::ProjectType, null: false
field :project_id, GraphQL::INT_TYPE, null: false, method: :target_project_id
field :source_project_id, GraphQL::INT_TYPE, null: true
field :target_project_id, GraphQL::INT_TYPE, null: false
field :source_branch, GraphQL::STRING_TYPE, null: false
field :target_branch, GraphQL::STRING_TYPE, null: false
field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false
field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true
field :diff_head_sha, GraphQL::STRING_TYPE, null: true
field :merge_commit_sha, GraphQL::STRING_TYPE, null: true
field :user_notes_count, GraphQL::INT_TYPE, null: true
field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true
field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true
field :merge_status, GraphQL::STRING_TYPE, null: true
field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true
field :merge_error, GraphQL::STRING_TYPE, null: true
field :allow_collaboration, GraphQL::BOOLEAN_TYPE, null: true
field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false
field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true
field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false
field :diff_head_sha, GraphQL::STRING_TYPE, null: true
field :merge_commit_message, GraphQL::STRING_TYPE, null: true
field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false
field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false
field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true
field :web_url, GraphQL::STRING_TYPE, null: true
field :upvotes, GraphQL::INT_TYPE, null: false
field :downvotes, GraphQL::INT_TYPE, null: false
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false
end
end
Types::MutationType = GraphQL::ObjectType.define do
name "Mutation"
module Types
class MutationType < BaseObject
graphql_name "Mutation"
# TODO: Add Mutations as fields
# TODO: Add Mutations as fields
end
end
Types::ProjectType = GraphQL::ObjectType.define do
name 'Project'
module Types
class ProjectType < BaseObject
graphql_name 'Project'
field :id, !types.ID
field :id, GraphQL::ID_TYPE, null: false
field :full_path, !types.ID
field :path, !types.String
field :full_path, GraphQL::ID_TYPE, null: false
field :path, GraphQL::STRING_TYPE, null: false
field :name_with_namespace, !types.String
field :name, !types.String
field :name_with_namespace, GraphQL::STRING_TYPE, null: false
field :name, GraphQL::STRING_TYPE, null: false
field :description, types.String
field :description, GraphQL::STRING_TYPE, null: true
field :default_branch, types.String
field :tag_list, types.String
field :default_branch, GraphQL::STRING_TYPE, null: true
field :tag_list, GraphQL::STRING_TYPE, null: true
field :ssh_url_to_repo, types.String
field :http_url_to_repo, types.String
field :web_url, types.String
field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true
field :http_url_to_repo, GraphQL::STRING_TYPE, null: true
field :web_url, GraphQL::STRING_TYPE, null: true
field :star_count, !types.Int
field :forks_count, !types.Int
field :star_count, GraphQL::INT_TYPE, null: false
field :forks_count, GraphQL::INT_TYPE, null: false
field :created_at, Types::TimeType
field :last_activity_at, Types::TimeType
field :created_at, Types::TimeType, null: true
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 :shared_runners_enabled, types.Boolean
field :lfs_enabled, types.Boolean
field :ff_only_enabled, types.Boolean, property: :merge_requests_ff_only_enabled
field :container_registry_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :shared_runners_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :avatar_url, types.String do
resolve ->(project, args, ctx) { project.avatar_url(only_path: false) }
end
field :avatar_url, GraphQL::STRING_TYPE, null: true, resolve: -> (project, args, ctx) do
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]) }
%i[issues merge_requests wiki snippets].each do |feature|
field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do
project.feature_available?(feature, ctx[:current_user])
end
end
end
field :jobs_enabled, types.Boolean do
resolve ->(project, args, ctx) { project.feature_available?(:builds, ctx[:current_user]) }
end
field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do
project.feature_available?(:builds, ctx[:current_user])
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
resolve ->(project, args, ctx) { project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) }
end
field :open_issues_count, GraphQL::INT_TYPE, null: true, resolve: -> (project, args, ctx) do
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 :import_status, GraphQL::STRING_TYPE, null: true
field :ci_config_path, GraphQL::STRING_TYPE, null: true
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
field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true
field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true
end
end
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"'
module Types
class QueryType < BaseObject
graphql_name 'Query'
field :project, Types::ProjectType,
null: true,
resolver: Resolvers::ProjectResolver,
description: "Find a project" do
authorize :read_project
end
argument :iid, !types.ID do
description 'The IID of the merge request, e.g., "1"'
field :merge_request, Types::MergeRequestType,
null: true,
resolver: Resolvers::MergeRequestResolver,
description: "Find a merge request" do
authorize :read_merge_request
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
field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new
end
end
# 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'
module Types
class TimeType < BaseScalar
graphql_name 'Time'
description 'Time represented in ISO 8601'
coerce_input ->(value, ctx) { Time.at(Float(value)) }
coerce_result ->(value, ctx) { value.to_f }
def self.coerce_input(value, ctx)
Time.parse(value)
end
def self.coerce_result(value, ctx)
value.iso8601
end
end
end
post '/api/graphql', to: 'graphql#execute'
mount GraphiQL::Rails::Engine, at: '/api/graphiql', graphql_path: '/api/graphql'
constraints(::Constraints::FeatureConstrainer.new(:graphql)) do
post '/api/graphql', to: 'graphql#execute'
mount GraphiQL::Rails::Engine, at: '/-/graphql-explorer', graphql_path: '/api/graphql'
end
API::API.logger Rails.logger
mount API::API => '/'
......@@ -2,33 +2,41 @@
> [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
feature flag. To enable it on your selfhosted instance, run
`Feature.enable(:graphql)`.
The GraphQL data (fields) can be described in the form of types,
allowing clients to use [clientside 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
sudo gitlab-rails console
```
## Enabling the GraphQL feature
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
Feature.enable(:graphql)
```shell
curl --data "value=100" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features/graphql
```
## 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`
or `MergeRequest`.
## GraphiQL
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
[features-api]: ../features.md
......@@ -2,8 +2,8 @@
## Authentication
Authentication happens through the `GrapqlController`, right now this
uses the same authentication as the rails application. So the session
Authentication happens through the `GraphqlController`, right now this
uses the same authentication as the Rails application. So the session
can be shared.
It is also possible to add a `private_token` to the querystring, or
......@@ -11,27 +11,25 @@ add a `HTTP_PRIVATE_TOKEN` header.
### 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:
```ruby
Types::QueryType = GraphQL::ObjectType.define do
name 'Query'
module Types
class QueryType < BaseObject
graphql_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"'
field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver do
authorize :read_project
end
authorize :read_project
resolve Loaders::FullPathLoader[:project]
end
end
```
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
......@@ -43,7 +41,7 @@ the definition as minimal as possible. Instead, consider moving any
logic into a presenter:
```ruby
Types::MergeRequestType = GraphQL::ObjectType.define do
class Types::MergeRequestType < BaseObject
present_using MergeRequestPresenter
name 'MergeRequest'
......@@ -56,11 +54,28 @@ a new presenter specifically for GraphQL.
The presenter is initialized using the object resolved by a field, and
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
_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
be used to test the query, it expects a valid `query` to be available
in the spec.