From 9c6c17cbcdb8bf8185fc1b873dcfd08f723e4df5 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Wed, 16 Aug 2017 14:04:41 +0100 Subject: Add a minimal GraphQL API --- 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 +++++ 11 files changed, 305 insertions(+) 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 (limited to 'app') diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb new file mode 100644 index 00000000000..ef258bf07cb --- /dev/null +++ b/app/controllers/graphql_controller.rb @@ -0,0 +1,49 @@ +class GraphqlController < ApplicationController + # Unauthenticated users have access to the API for public data + skip_before_action :authenticate_user! + + before_action :check_graphql_feature_flag! + + def execute + variables = ensure_hash(params[:variables]) + query = params[:query] + operation_name = params[:operationName] + context = { + current_user: current_user + } + result = GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name) + render json: result + end + + private + + # Overridden from the ApplicationController to make the response look like + # a GraphQL response. That is nicely picked up in Graphiql. + def render_404 + error = { errors: [ message: "Not found" ] } + + render json: error, status: :not_found + end + + def check_graphql_feature_flag! + render_404 unless Feature.enabled?(:graphql) + end + + # Handle form data, JSON body, or a blank value + def ensure_hash(ambiguous_param) + case ambiguous_param + when String + if ambiguous_param.present? + ensure_hash(JSON.parse(ambiguous_param)) + else + {} + end + when Hash, ActionController::Parameters + ambiguous_param + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{ambiguous_param}" + end + end +end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb new file mode 100644 index 00000000000..7392bf6f503 --- /dev/null +++ b/app/graphql/gitlab_schema.rb @@ -0,0 +1,11 @@ +Gitlab::Graphql::Authorize.register! + +GitlabSchema = GraphQL::Schema.define do + use GraphQL::Batch + + enable_preloading + enable_authorization + + mutation(Types::MutationType) + query(Types::QueryType) +end diff --git a/app/graphql/loaders/base_loader.rb b/app/graphql/loaders/base_loader.rb new file mode 100644 index 00000000000..c32c4daa91a --- /dev/null +++ b/app/graphql/loaders/base_loader.rb @@ -0,0 +1,24 @@ +# Helper methods for all loaders +class Loaders::BaseLoader < GraphQL::Batch::Loader + # Convert a class method into a resolver proc. The method should follow the + # (obj, args, ctx) calling convention + class << self + def [](sym) + resolver = method(sym) + raise ArgumentError.new("#{self}.#{sym} is not a resolver") unless resolver.arity == 3 + + resolver + end + end + + # Fulfill all keys. Pass a block that converts each result into a key. + # Any keys not in results will be fulfilled with nil. + def fulfill_all(results, keys, &key_blk) + results.each do |result| + key = yield result + fulfill(key, result) + end + + keys.each { |key| fulfill(key, nil) unless fulfilled?(key) } + end +end diff --git a/app/graphql/loaders/full_path_loader.rb b/app/graphql/loaders/full_path_loader.rb new file mode 100644 index 00000000000..f99b487ce5d --- /dev/null +++ b/app/graphql/loaders/full_path_loader.rb @@ -0,0 +1,23 @@ +class Loaders::FullPathLoader < Loaders::BaseLoader + class << self + def project(obj, args, ctx) + project_by_full_path(args[:full_path]) + end + + def project_by_full_path(full_path) + self.for(Project).load(full_path) + end + end + + attr_reader :model + + def initialize(model) + @model = model + end + + def perform(keys) + # `with_route` prevents relation.all.map(&:full_path)` from being N+1 + relation = model.where_full_path_in(keys).with_route + fulfill_all(relation, keys, &:full_path) + end +end diff --git a/app/graphql/loaders/iid_loader.rb b/app/graphql/loaders/iid_loader.rb new file mode 100644 index 00000000000..e89031da0c2 --- /dev/null +++ b/app/graphql/loaders/iid_loader.rb @@ -0,0 +1,35 @@ +class Loaders::IidLoader < Loaders::BaseLoader + class << self + def merge_request(obj, args, ctx) + iid = args[:iid] + promise = Loaders::FullPathLoader.project_by_full_path(args[:project]) + + promise.then do |project| + if project + merge_request_by_project_and_iid(project.id, iid) + else + nil + end + end + end + + def merge_request_by_project_and_iid(project_id, iid) + self.for(MergeRequest, target_project_id: project_id.to_s).load(iid.to_s) + end + end + + attr_reader :model, :restrictions + + def initialize(model, restrictions = {}) + @model = model + @restrictions = restrictions + end + + def perform(keys) + relation = model.where(iid: keys) + relation = relation.where(restrictions) if restrictions.present? + + # IIDs are represented as the GraphQL `id` type, which is a string + fulfill_all(relation, keys) { |instance| instance.iid.to_s } + end +end diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb new file mode 100644 index 00000000000..9b12f6f2bf3 --- /dev/null +++ b/app/graphql/types/merge_request_type.rb @@ -0,0 +1,50 @@ +Types::MergeRequestType = GraphQL::ObjectType.define do + name 'MergeRequest' + + field :id, !types.ID + field :iid, !types.ID + field :title, types.String + field :description, types.String + field :state, types.String + + field :created_at, Types::TimeType + field :updated_at, Types::TimeType + + field :source_project, -> { Types::ProjectType } + field :target_project, -> { Types::ProjectType } + + # Alias for target_project + field :project, -> { Types::ProjectType } + + field :source_project_id, types.Int + field :target_project_id, types.Int + field :project_id, types.Int + + field :source_branch, types.String + field :target_branch, types.String + + field :work_in_progress, types.Boolean, property: :work_in_progress? + field :merge_when_pipeline_succeeds, types.Boolean + + field :sha, types.String, property: :diff_head_sha + field :merge_commit_sha, types.String + + field :user_notes_count, types.Int + field :should_remove_source_branch, types.Boolean, property: :should_remove_source_branch? + field :force_remove_source_branch, types.Boolean, property: :force_remove_source_branch? + + field :merge_status, types.String + + field :web_url, types.String do + resolve ->(merge_request, args, ctx) { Gitlab::UrlBuilder.build(merge_request) } + end + + field :upvotes, types.Int + field :downvotes, types.Int + + field :subscribed, types.Boolean do + resolve ->(merge_request, args, ctx) do + merge_request.subscribed?(ctx[:current_user], merge_request.target_project) + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb new file mode 100644 index 00000000000..c5061f10239 --- /dev/null +++ b/app/graphql/types/mutation_type.rb @@ -0,0 +1,5 @@ +Types::MutationType = GraphQL::ObjectType.define do + name "Mutation" + + # TODO: Add Mutations as fields +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb new file mode 100644 index 00000000000..bfefc594896 --- /dev/null +++ b/app/graphql/types/project_type.rb @@ -0,0 +1,62 @@ +Types::ProjectType = GraphQL::ObjectType.define do + name 'Project' + + field :id, !types.ID + + field :full_path, !types.ID + field :path, !types.String + + field :name_with_namespace, !types.String + field :name, !types.String + + field :description, types.String + + field :default_branch, types.String + field :tag_list, types.String + + field :ssh_url_to_repo, types.String + field :http_url_to_repo, types.String + field :web_url, types.String + + field :star_count, !types.Int + field :forks_count, !types.Int + + field :created_at, Types::TimeType + field :last_activity_at, Types::TimeType + + field :archived, types.Boolean + + field :visibility, types.String + + field :container_registry_enabled, types.Boolean + field :shared_runners_enabled, types.Boolean + field :lfs_enabled, types.Boolean + + field :avatar_url, types.String do + resolve ->(project, args, ctx) { project.avatar_url(only_path: false) } + end + + %i[issues merge_requests wiki snippets].each do |feature| + field "#{feature}_enabled", types.Boolean do + resolve ->(project, args, ctx) { project.feature_available?(feature, ctx[:current_user]) } + end + end + + field :jobs_enabled, types.Boolean do + resolve ->(project, args, ctx) { project.feature_available?(:builds, ctx[:current_user]) } + end + + field :public_jobs, types.Boolean, property: :public_builds + + field :open_issues_count, types.Int do + resolve ->(project, args, ctx) { project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) } + end + + field :import_status, types.String + field :ci_config_path, types.String + + field :only_allow_merge_if_pipeline_succeeds, types.Boolean + field :request_access_enabled, types.Boolean + field :only_allow_merge_if_all_discussions_are_resolved, types.Boolean + field :printing_merge_request_link_enabled, types.Boolean +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb new file mode 100644 index 00000000000..029bbd098ad --- /dev/null +++ b/app/graphql/types/query_type.rb @@ -0,0 +1,38 @@ +Types::QueryType = GraphQL::ObjectType.define do + name 'Query' + + field :project, Types::ProjectType do + argument :full_path, !types.ID do + description 'The full path of the project, e.g., "gitlab-org/gitlab-ce"' + end + + authorize :read_project + + resolve Loaders::FullPathLoader[:project] + end + + field :merge_request, Types::MergeRequestType do + argument :project, !types.ID do + description 'The full path of the target project, e.g., "gitlab-org/gitlab-ce"' + end + + argument :iid, !types.ID do + description 'The IID of the merge request, e.g., "1"' + end + + authorize :read_merge_request + + resolve Loaders::IidLoader[:merge_request] + end + + # Testing endpoint to validate the API with + field :echo, types.String do + argument :text, types.String + + resolve -> (obj, args, ctx) do + username = ctx[:current_user]&.username + + "#{username.inspect} says: #{args[:text]}" + end + end +end diff --git a/app/graphql/types/time_type.rb b/app/graphql/types/time_type.rb new file mode 100644 index 00000000000..fb717eb3dc7 --- /dev/null +++ b/app/graphql/types/time_type.rb @@ -0,0 +1,8 @@ +# Taken from http://www.rubydoc.info/github/rmosolgo/graphql-ruby/GraphQL/ScalarType +Types::TimeType = GraphQL::ScalarType.define do + name 'Time' + description 'Time since epoch in fractional seconds' + + coerce_input ->(value, ctx) { Time.at(Float(value)) } + coerce_result ->(value, ctx) { value.to_f } +end -- cgit v1.2.1