-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
module Types | ||
class MutationType < Types::BaseObject | ||
end | ||
end |
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 |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
# frozen_string_literal: true | ||
|
||
module Types | ||
class SearchEventType < Types::BaseObject | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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 |
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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
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 |
There was a problem hiding this comment.
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.