Skip to content

Add a GraphQL endpoint #134

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Feb 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
---
require: rubocop-graphql
inherit_from:
- https://raspberrypifoundation.github.io/digital-engineering/configs/rubocop-base.yml
- https://raspberrypifoundation.github.io/digital-engineering/configs/rubocop-rails.yml
- https://raspberrypifoundation.github.io/digital-engineering/configs/rubocop-rspec.yml
- .rubocop_todo.yml

# RPF Digital Products house styles

# Include your pre-existing rubocop_todo.yaml here, if you have one.
# - .rubocop_todo.yml

# Allow the Exclude arrays to be merged.
inherit_mode:
merge:
- Exclude

GraphQL/ObjectDescription:
Exclude:
- app/graphql/types/mutation_type.rb
- app/graphql/types/node_type.rb
- app/graphql/types/query_type.rb

RSpec/NestedGroups:
Max: 4

RSpec/DescribeClass:
Exclude:
- "spec/graphql/queries/**"
- "spec/graphql/mutations/**"

11 changes: 2 additions & 9 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2022-02-10 15:43:19 UTC using RuboCop version 1.23.0.
# on 2023-02-20 08:27:43 UTC using RuboCop version 1.39.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.

# Offense count: 4
# Offense count: 2
Lint/UselessAssignment:
Exclude:
- 'lib/tasks/projects.rake'

# Offense count: 4
# Configuration parameters: ForbiddenDelimiters.
# ForbiddenDelimiters: (?-mix:(^|\s)(EO[A-Z]{1}|END)(\s|$))
Naming/HeredocDelimiterNaming:
Exclude:
- 'lib/tasks/projects.rake'
6 changes: 4 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ gem 'bootsnap', require: false
gem 'cancancan', '~> 3.3'
gem 'faraday'
gem 'github_webhook', '~> 1.4'
gem 'globalid'
gem 'good_job', '~> 3.12'
gem 'graphql'
gem 'importmap-rails'
gem 'jbuilder'
gem 'kaminari'
Expand All @@ -28,6 +31,7 @@ group :development, :test do
gem 'rspec_junit_formatter'
gem 'rspec-rails'
gem 'rubocop', require: false
gem 'rubocop-graphql', require: false
gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false
gem 'simplecov', require: false
Expand All @@ -38,5 +42,3 @@ group :test do
gem 'shoulda-matchers', '~> 5.0'
gem 'webmock'
end

gem 'good_job', '~> 3.12'
6 changes: 6 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ GEM
railties (>= 6.0.0)
thor (>= 0.14.1)
webrick (>= 1.3)
graphql (2.0.17)
hashdiff (1.0.1)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
Expand Down Expand Up @@ -264,6 +265,8 @@ GEM
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.23.0)
parser (>= 3.1.1.0)
rubocop-graphql (0.19.0)
rubocop (>= 0.87, < 2)
rubocop-rails (2.17.3)
activesupport (>= 4.2.0)
rack (>= 1.1)
Expand Down Expand Up @@ -314,7 +317,9 @@ DEPENDENCIES
faker
faraday
github_webhook (~> 1.4)
globalid
good_job (~> 3.12)
graphql
importmap-rails
jbuilder
kaminari
Expand All @@ -327,6 +332,7 @@ DEPENDENCIES
rspec-rails
rspec_junit_formatter
rubocop
rubocop-graphql
rubocop-rails
rubocop-rspec
sentry-rails (~> 5.5.0)
Expand Down
16 changes: 16 additions & 0 deletions app/controllers/concerns/authentication_concern.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

require 'hydra_admin_api'

module AuthenticationConcern
extend ActiveSupport::Concern

def current_user_id
return @current_user_id if @current_user_id

token = request.headers['Authorization']
return nil unless token

@current_user_id = HydraAdminApi.fetch_oauth_user_id(token:)
end
end
66 changes: 66 additions & 0 deletions app/controllers/graphql_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

class GraphqlController < ApplicationController
include AuthenticationConcern
include ActiveStorage::SetCurrent if Rails.env.development?

skip_before_action :verify_authenticity_token

# If accessing from outside this domain, nullify the session
# This allows for outside API access while preventing CSRF attacks,
# but you'll have to authenticate your user separately
# protect_from_forgery with: :null_session

def execute
result = EditorApiSchema.execute(query, variables:, context:, operation_name:)
render json: result
rescue StandardError => e
raise e unless Rails.env.development?

handle_error_in_development(e)
end

private

def query
params[:query]
end

def operation_name
params[:operationName]
end

def context
@context ||= { current_user_id:, current_ability: Ability.new(current_user_id) }
end

# Handle variables in form data, JSON body, or a blank value
def variables
variables_param = params[:variables]

case params[:variables]
when String
if variables_param.present?
JSON.parse(variables_param) || {}
else
{}
end
when Hash
variables_param
when ActionController::Parameters
variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables.
when nil
{}
else
raise ArgumentError, "Unexpected parameter: #{variables_param}"
end
end

def handle_error_in_development(error)
logger.error error.message
logger.error error.backtrace.join("\n")

render json: { errors: [{ message: error.message, backtrace: error.backtrace }], data: {} },
status: :internal_server_error
end
end
53 changes: 53 additions & 0 deletions app/graphql/editor_api_schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

class EditorApiSchema < GraphQL::Schema
mutation Types::MutationType
query Types::QueryType

# For batch-loading (see https://graphql-ruby.org/dataloader/overview.html)
use GraphQL::Dataloader

# GraphQL-Ruby calls this when something goes wrong while running a query:

# Union and Interface Resolution
def self.resolve_type(_abstract_type, obj, _ctx)
case obj
when Project
Types::ProjectType
when Component
Types::ComponentType
else
raise("Unexpected object: #{obj}")
end
end

def self.unauthorized_object(error)
# Add a top-level error to the response instead of returning nil:
raise GraphQL::ExecutionError, "An object of type #{error.type.graphql_name} was hidden due to permissions"
end

def self.unauthorized_field(error)
# Add a top-level error to the response instead of returning nil:
raise GraphQL::ExecutionError,
"The field #{error.field.graphql_name} on " \
"an object of type #{error.type.graphql_name} was hidden due to permissions"
end

# Stop validating when it encounters this many errors:
validate_max_errors 100
default_max_page_size 10

# Relay-style Object Identification:

# Return a string UUID for `object`
def self.id_from_object(object, _type_definition, _query_ctx)
# For example, use Rails' GlobalID library (https://github.com/rails/globalid):
object.to_gid_param
end

# Given a string UUID, find the object
def self.object_from_id(global_id, _query_ctx)
# For example, use Rails' GlobalID library (https://github.com/rails/globalid):
GlobalID.find(global_id)
end
end
Empty file added app/graphql/mutations/.keep
Empty file.
10 changes: 10 additions & 0 deletions app/graphql/mutations/base_mutation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
argument_class Types::BaseArgument
field_class Types::BaseField
input_object_class Types::BaseInputObject
object_class Types::BaseObject
end
end
31 changes: 31 additions & 0 deletions app/graphql/mutations/create_project.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module Mutations
class CreateProject < BaseMutation
description 'A mutation to create a new project'

field :project, Types::ProjectType, description: 'The project that has been created'

# rubocop:disable GraphQL/ExtractInputType
argument :components, [Types::ComponentInputType], required: false, description: 'Any project components'
argument :name, String, required: true, description: 'The name of the project'
argument :project_type, String, required: true, description: 'The type of project, e.g. python, html'
# rubocop:enable GraphQL/ExtractInputType

def resolve(**input)
project_hash = input.merge(user_id: context[:current_user_id],
components: input[:components]&.map(&:to_h))

response = Project::Create.call(project_hash:)
raise GraphQL::ExecutionError, response[:error] unless response.success?

{ project: response[:project] }
end

def ready?(**_args)
return true if context[:current_ability]&.can?(:create, Project, user_id: context[:current_user_id])

raise GraphQL::ExecutionError, 'You are not permitted to create a project'
end
end
end
Empty file added app/graphql/types/.keep
Empty file.
6 changes: 6 additions & 0 deletions app/graphql/types/base_argument.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

module Types
class BaseArgument < GraphQL::Schema::Argument
end
end
14 changes: 14 additions & 0 deletions app/graphql/types/base_connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module Types
class BaseConnection < Types::BaseObject
# add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides
include GraphQL::Types::Relay::ConnectionBehaviors

field :total_count, Int, null: false, description: 'Total number of nodes available'

def total_count
object.items.size
end
end
end
8 changes: 8 additions & 0 deletions app/graphql/types/base_edge.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module Types
class BaseEdge < Types::BaseObject
# add `node` and `cursor` fields, as well as `node_type(...)` override
include GraphQL::Types::Relay::EdgeBehaviors
end
end
6 changes: 6 additions & 0 deletions app/graphql/types/base_enum.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

module Types
class BaseEnum < GraphQL::Schema::Enum
end
end
7 changes: 7 additions & 0 deletions app/graphql/types/base_field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module Types
class BaseField < GraphQL::Schema::Field
argument_class Types::BaseArgument
end
end
7 changes: 7 additions & 0 deletions app/graphql/types/base_input_object.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module Types
class BaseInputObject < GraphQL::Schema::InputObject
argument_class Types::BaseArgument
end
end
11 changes: 11 additions & 0 deletions app/graphql/types/base_interface.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module Types
module BaseInterface
include GraphQL::Schema::Interface
edge_type_class(Types::BaseEdge)
connection_type_class(Types::BaseConnection)

field_class Types::BaseField
end
end
9 changes: 9 additions & 0 deletions app/graphql/types/base_object.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module Types
class BaseObject < GraphQL::Schema::Object
edge_type_class(Types::BaseEdge)
connection_type_class(Types::BaseConnection)
field_class Types::BaseField
end
end
6 changes: 6 additions & 0 deletions app/graphql/types/base_scalar.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

module Types
class BaseScalar < GraphQL::Schema::Scalar
end
end
8 changes: 8 additions & 0 deletions app/graphql/types/base_union.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

module Types
class BaseUnion < GraphQL::Schema::Union
edge_type_class(Types::BaseEdge)
connection_type_class(Types::BaseConnection)
end
end
Loading