Skip to content

Commit

Permalink
Merge pull request #11 from MITLibraries/engx-234-implement-graphql
Browse files Browse the repository at this point in the history
Initial GraphQL implementation
  • Loading branch information
jazairi authored Nov 3, 2023
2 parents ae113b7 + a99f2b4 commit 0b587a2
Show file tree
Hide file tree
Showing 23 changed files with 307 additions and 0 deletions.
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ end
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false

# Ruby GraphQL implememntation [https://github.com/rmosolgo/graphql-ruby]
gem 'graphql'

# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem 'importmap-rails'

Expand Down Expand Up @@ -57,6 +60,9 @@ group :development do
# Add annotations to model, test, fixtures when run
gem 'annotate'

# GraphQL query editor
gem "graphiql-rails"

# RuboCop is a Ruby static code analyzer (a.k.a. linter) and code formatter.
gem 'rubocop'
gem 'rubocop-capybara'
Expand Down
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ GEM
erubi (1.12.0)
globalid (1.2.1)
activesupport (>= 6.1)
graphiql-rails (1.9.0)
railties
sprockets-rails
graphql (2.1.3)
racc (~> 1.4)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
importmap-rails (1.2.1)
Expand Down Expand Up @@ -287,6 +292,8 @@ DEPENDENCIES
bootsnap
capybara
debug
graphiql-rails
graphql
importmap-rails
jbuilder
puma (>= 5.0)
Expand Down
52 changes: 52 additions & 0 deletions app/controllers/graphql_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

class GraphqlController < ApplicationController
# 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
variables = prepare_variables(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
# Query context goes here, for example:
# current_user: current_user,
}
result = TacosSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
render json: result
rescue StandardError => e
raise e unless Rails.env.development?
handle_error_in_development(e)
end

private

# Handle variables in form data, JSON body, or a blank value
def prepare_variables(variables_param)
case variables_param
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(e)
logger.error e.message
logger.error e.backtrace.join("\n")

render json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} }, status: 500
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
41 changes: 41 additions & 0 deletions app/graphql/tacos_schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

class TacosSchema < GraphQL::Schema
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:
def self.type_error(err, context)
# if err.is_a?(GraphQL::InvalidNullError)
# # report to your bug tracker here
# return nil
# end
super
end

# Union and Interface Resolution
def self.resolve_type(abstract_type, obj, ctx)
# TODO: Implement this method
# to return the correct GraphQL object type for `obj`
raise(GraphQL::RequiredImplementationMissingError)
end

# Stop validating when it encounters this many errors:
validate_max_errors(100)

# 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/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
8 changes: 8 additions & 0 deletions app/graphql/types/base_connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 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
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
4 changes: 4 additions & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module Types
class MutationType < Types::BaseObject
end
end
9 changes: 9 additions & 0 deletions app/graphql/types/node_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module Types
module NodeType
include Types::BaseInterface
# Add the `id` field
include GraphQL::Types::Relay::NodeBehaviors
end
end
36 changes: 36 additions & 0 deletions app/graphql/types/query_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module Types
class QueryType < Types::BaseObject
field :node, Types::NodeType, null: true, description: 'Fetches an object given its ID.' do
argument :id, ID, required: true, description: 'ID of the object.'
end

def node(id:)
context.schema.object_from_id(id, context)
end

field :nodes, [Types::NodeType, { null: true }], null: true,
description: 'Fetches a list of objects given a list of IDs.' do
argument :ids, [ID], required: true, description: 'IDs of the objects.'
end

def nodes(ids:)
ids.map { |id| context.schema.object_from_id(id, context) }
end

# Add root-level fields here.
# They will be entry points for queries on your schema.

field :log_search_event, SearchEventType, null: false,
description: 'Log a search and return information about it.' do
argument :search_term, String, required: true
argument :source_system, String, required: true
end

def log_search_event(search_term:, source_system:)
term = Term.create_or_find_by!(phrase: search_term)
term.search_events.create!(source: source_system)
end
end
end
11 changes: 11 additions & 0 deletions app/graphql/types/search_event_type.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module Types
class SearchEventType < Types::BaseObject
field :id, ID, null: false
field :term_id, Integer
field :source, String
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
end
end
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
Rails.application.routes.draw do
if Rails.env.development?
mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql"
end
post "/graphql", to: "graphql#execute"
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
Expand Down
51 changes: 51 additions & 0 deletions test/controllers/graphql_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
require 'test_helper'

class GraphqlControllerTest < ActionDispatch::IntegrationTest
test 'search event query returns relevant data' do
post '/graphql', params: { query: '{
logSearchEvent(sourceSystem: "bento", searchTerm: "range life") {
termId
source
createdAt
updatedAt
}
}' }
assert_equal(200, response.status)
json = JSON.parse(response.body)
term_id = Term.last.id

assert_equal 'bento', json['data']['logSearchEvent']['source']
assert_equal term_id, json['data']['logSearchEvent']['termId']
assert_equal Date.today, json['data']['logSearchEvent']['createdAt'].to_date
assert_equal Date.today, json['data']['logSearchEvent']['updatedAt'].to_date
end

test 'search event query creates a new term if one does not exist' do
initial_term_count = Term.count
post '/graphql', params: { query: '{
logSearchEvent(sourceSystem: "bento", searchTerm: "range life") {
termId
source
createdAt
updatedAt
}
}' }
assert_equal(200, response.status)
assert_equal Term.count, (initial_term_count + 1)
assert_equal 'range life', Term.last.phrase
end

test 'search event query does not create a new term if phrase is already stored' do
initial_term_count = Term.count
post '/graphql', params: { query: '{
logSearchEvent(sourceSystem: "timdex", searchTerm: "Super cool search") {
termId
source
createdAt
updatedAt
}
}' }
assert_equal(200, response.status)
assert_equal Term.count, initial_term_count
end
end

0 comments on commit 0b587a2

Please sign in to comment.