Skip to content
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

Initial GraphQL implementation #11

Merged
merged 3 commits into from
Nov 3, 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
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want to change this now, but I think we may want to be open to changing this name as we start integrating this work with other systems.

TACOS cares about the logging portion of this, but consumers of TACOS in the future are hopefully less concerned with logging and more interested in the structured data we can return. It's also possible there are multiple access points, this one that just logs and a different one in the future that would log and return data... hence my not wanting to worry about the name quite yet as this is currently is well named :)

As the current targeted use is entirely internal for the near future, we can easily update this if we come up with a name that feels more accurate.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought for the future: I'm waffly (and thus not requesting a change) as to whether it might be good to include the Term.phrase as part of this externally facing object. I know from the data perspective, it is a relation but from the API perspective we may want this to feel like a single object. Hmmm.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same thought and, being similarly ambivalent, stuck to what is contained in the SearchEvent model for now. My expectation is that this type will eventually include a lot of data that it does not currently, including the search phrase. (As you noted, the field name will probably also change to reflect this.) For now, sticking with a basic implementation feels to me like a good approach, given that we'll be extending it in the hopefully near future.

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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In TIMDEX we've decided to implement GraphiQL as an HTML page instead of via the Gem. I don't remember why, so this isn't a request for a change but just something we should keep our eye on to know whether we should update TIMDEX or TACOS to match the other.

https://github.com/MITLibraries/timdex/blob/main/public/playground.html

If we can configure the Gem version, it may be better as it would fit into our dependency update patterns more easily than the standalone HTML does. 🤷🏻

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
Loading