Merge pull request #4766 from tonekk/refactor_graphql

Add new GraphQL types & schema
This commit is contained in:
Javi Martín
2022-06-01 12:18:47 +02:00
committed by GitHub
24 changed files with 315 additions and 363 deletions

View File

@@ -6,52 +6,56 @@ class GraphqlController < ApplicationController
skip_before_action :verify_authenticity_token
skip_authorization_check
class QueryStringError < StandardError
end
class QueryStringError < StandardError; end
def query
def execute
begin
if query_string.nil? then raise GraphqlController::QueryStringError end
raise GraphqlController::QueryStringError if query_string.nil?
response = consul_schema.execute query_string, variables: query_variables
render json: response, status: :ok
result = ConsulSchema.execute(query_string,
variables: prepare_variables,
context: {},
operation_name: params[:operationName]
)
render json: result
rescue GraphqlController::QueryStringError
render json: { message: "Query string not present" }, status: :bad_request
rescue JSON::ParserError
render json: { message: "Error parsing JSON" }, status: :bad_request
rescue GraphQL::ParseError
render json: { message: "Query string is not valid JSON" }, status: :bad_request
rescue
unless Rails.env.production? then raise end
rescue ArgumentError => e
render json: { message: e.message }, status: :bad_request
end
end
private
def consul_schema
api_types = GraphQL::ApiTypesCreator.create
query_type = GraphQL::QueryTypeCreator.create(api_types)
GraphQL::Schema.define do
query query_type
max_depth 8
max_complexity 2500
end
end
def query_string
if request.headers["CONTENT_TYPE"] == "application/graphql"
request.body.string # request.body.class => StringIO
request.body.string
else
params[:query]
end
end
def query_variables
if params[:variables].blank? || params[:variables] == "null"
# Handle variables in URL query string and JSON body
def prepare_variables
case variables_param = params[:variables]
# URL query string
when String
if variables_param.present?
JSON.parse(variables_param) || {}
else
{}
end
# JSON object in request body gets converted to ActionController::Parameters
when ActionController::Parameters
variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables.
when nil
{}
else
JSON.parse(params[:variables])
raise ArgumentError, "Unexpected parameter: #{variables_param}"
end
end
end

View File

@@ -0,0 +1,11 @@
class ConsulSchema < GraphQL::Schema
mutation(Types::MutationType)
query(Types::QueryType)
# Opt in to the new runtime (default in future graphql-ruby versions)
use GraphQL::Execution::Interpreter
use GraphQL::Analysis::AST
# Add built-in connections for pagination
use GraphQL::Pagination::Connections
end

View File

@@ -0,0 +1,5 @@
module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
object_class Types::BaseObject
end
end

View File

@@ -0,0 +1,29 @@
module Types
class BaseObject < GraphQL::Schema::Object
def self.field(*args, **kwargs, &block)
super(*args, **kwargs, &block)
# The old api contained non-camelized fields
# We want to support these for now, but throw a deprecation warning
#
# Example:
# proposal_notifications => Deprecation warning (Old api)
# proposalNotifications => No deprecation warning (New api)
field_name = args[0]
if field_name.to_s.include?("_")
reason = "Snake case fields are deprecated. Please use #{field_name.to_s.camelize(:lower)}."
kwargs = kwargs.merge({ camelize: false, deprecation_reason: reason })
super(*args, **kwargs, &block)
end
# Make sure associations only return public records
# by automatically calling 'public_for_api'
type_class = args[1]
if type_class.is_a?(Class) && type_class.ancestors.include?(GraphQL::Types::Relay::BaseConnection)
define_method(field_name) { object.send(field_name).public_for_api }
end
end
end
end

View File

@@ -0,0 +1,16 @@
module Types
class CommentType < Types::BaseObject
field :ancestry, String, null: true
field :body, String, null: true
field :cached_votes_down, Integer, null: true
field :cached_votes_total, Integer, null: true
field :cached_votes_up, Integer, null: true
field :commentable_id, Integer, null: true
field :commentable_type, String, null: true
field :confidence_score, Integer, null: false
field :id, ID, null: false
field :public_author, Types::UserType, null: true
field :public_created_at, String, null: true
field :votes_for, Types::VoteType.connection_type, null: true
end
end

View File

@@ -0,0 +1,22 @@
module Types
class DebateType < Types::BaseObject
field :cached_votes_down, Integer, null: true
field :cached_votes_total, Integer, null: true
field :cached_votes_up, Integer, null: true
field :comments, Types::CommentType.connection_type, null: true
field :comments_count, Integer, null: true
field :confidence_score, Integer, null: true
field :description, String, null: true
field :hot_score, Integer, null: true
field :id, ID, null: false
field :public_author, Types::UserType, null: true
field :public_created_at, String, null: true
field :tags, Types::TagType.connection_type, null: true
field :title, String, null: true
field :votes_for, Types::VoteType.connection_type, null: true
def tags
object.tags.public_for_api
end
end
end

View File

@@ -0,0 +1,6 @@
module Types
class GeozoneType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: true
end
end

View File

@@ -0,0 +1,4 @@
module Types
class MutationType < Types::BaseObject
end
end

View File

@@ -0,0 +1,14 @@
module Types
class ProposalNotificationType < Types::BaseObject
field :body, String, null: true
field :id, ID, null: false
field :proposal, Types::ProposalType, null: true
field :proposal_id, Integer, null: true
field :public_created_at, String, null: true
field :title, String, null: true
def proposal
Proposal.public_for_api.find_by(id: object.proposal)
end
end
end

View File

@@ -0,0 +1,32 @@
module Types
class ProposalType < Types::BaseObject
field :cached_votes_up, Integer, null: true
field :comments, Types::CommentType.connection_type, null: true
field :comments_count, Integer, null: true
field :confidence_score, Integer, null: true
field :description, String, null: true
field :geozone, Types::GeozoneType, null: true
field :geozone_id, Integer, null: true
field :hot_score, Integer, null: true
field :id, ID, null: false
field :proposal_notifications, Types::ProposalNotificationType.connection_type, null: true
field :public_author, Types::UserType, null: true
field :public_created_at, String, null: true
field :retired_at, GraphQL::Types::ISO8601DateTime, null: true
field :retired_explanation, String, null: true
field :retired_reason, String, null: true
field :summary, String, null: true
field :tags, Types::TagType.connection_type, null: true
field :title, String, null: true
field :video_url, String, null: true
field :votes_for, Types::VoteType.connection_type, null: true
def tags
object.tags.public_for_api
end
def geozone
Geozone.public_for_api.find_by(id: object.geozone)
end
end
end

View File

@@ -0,0 +1,107 @@
module Types
class QueryType < Types::BaseObject
field :comments, Types::CommentType.connection_type, "Returns all comments", null: false
field :comment, Types::CommentType, "Returns comment for ID", null: false do
argument :id, ID, required: true, default_value: false
end
field :debates, Types::DebateType.connection_type, "Returns all debates", null: false
field :debate, Types::DebateType, "Returns debate for ID", null: false do
argument :id, ID, required: true, default_value: false
end
field :geozones, Types::GeozoneType.connection_type, "Returns all geozones", null: false
field :geozone, Types::GeozoneType, "Returns geozone for ID", null: false do
argument :id, ID, required: true, default_value: false
end
field :proposals, Types::ProposalType.connection_type, "Returns all proposals", null: false
field :proposal, Types::ProposalType, "Returns proposal for ID", null: false do
argument :id, ID, required: true, default_value: false
end
field :proposal_notifications, Types::ProposalNotificationType.connection_type, "Returns all proposal notifications", null: false
field :proposal_notification, Types::ProposalNotificationType, "Returns proposal notification for ID", null: false do
argument :id, ID, required: true, default_value: false
end
field :tags, Types::TagType.connection_type, "Returns all tags", null: false
field :tag, Types::TagType, "Returns tag for ID", null: false do
argument :id, ID, required: true, default_value: false
end
field :users, Types::UserType.connection_type, "Returns all users", null: false
field :user, Types::UserType, "Returns user for ID", null: false do
argument :id, ID, required: true, default_value: false
end
field :votes, Types::VoteType.connection_type, "Returns all votes", null: false
field :vote, Types::VoteType, "Returns vote for ID", null: false do
argument :id, ID, required: true, default_value: false
end
def comments
Comment.public_for_api
end
def comment(id:)
Comment.find(id)
end
def debates
Debate.public_for_api
end
def debate(id:)
Debate.find(id)
end
def geozones
Geozone.public_for_api
end
def geozone(id:)
Geozone.find(id)
end
def proposals
Proposal.public_for_api
end
def proposal(id:)
Proposal.find(id)
end
def proposal_notifications
ProposalNotification.public_for_api
end
def proposal_notification(id:)
ProposalNotification.find(id)
end
def tags
Tag.public_for_api
end
def tag(id:)
Tag.find(id)
end
def users
User.public_for_api
end
def user(id:)
User.find(id)
end
def votes
Vote.public_for_api
end
def vote(id:)
Vote.find(id)
end
end
end

View File

@@ -0,0 +1,8 @@
module Types
class TagType < Types::BaseObject
field :id, ID, null: false
field :kind, String, null: true
field :name, String, null: true
field :taggings_count, Integer, null: true
end
end

View File

@@ -0,0 +1,9 @@
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :public_comments, Types::CommentType.connection_type, null: true
field :public_debates, Types::DebateType.connection_type, null: true
field :public_proposals, Types::ProposalType.connection_type, null: true
field :username, String, null: true
end
end

View File

@@ -0,0 +1,9 @@
module Types
class VoteType < Types::BaseObject
field :id, ID, null: false
field :public_created_at, String, null: true
field :votable_id, Integer, null: true
field :votable_type, String, null: true
field :vote_flag, Boolean, null: true
end
end

View File

@@ -1,32 +1,6 @@
module Graphqlable
extend ActiveSupport::Concern
class_methods do
def graphql_field_name
name.gsub("::", "_").underscore.to_sym
end
def graphql_field_description
"Find one #{model_name.human} by ID"
end
def graphql_pluralized_field_name
name.gsub("::", "_").underscore.pluralize.to_sym
end
def graphql_pluralized_field_description
"Find all #{model_name.human.pluralize}"
end
def graphql_type_name
name.gsub("::", "_")
end
def graphql_type_description
model_name.human.to_s
end
end
def public_created_at
created_at.change(min: 0)
end

View File

@@ -1,5 +1,5 @@
module HasPublicAuthor
def public_author
author.public_activity? ? author : nil
author.public_activity? ? User.public_for_api.find_by(id: author) : nil
end
end

View File

@@ -1,82 +0,0 @@
User:
fields:
id: integer
username: string
public_debates: [Debate]
public_proposals: [Proposal]
public_comments: [Comment]
Debate:
fields:
id: integer
title: string
description: string
public_created_at: string
cached_votes_total: integer
cached_votes_up: integer
cached_votes_down: integer
comments_count: integer
hot_score: integer
confidence_score: integer
comments: [Comment]
public_author: User
votes_for: [Vote]
tags: [Tag]
Proposal:
fields:
id: integer
title: string
description: string
cached_votes_up: integer
comments_count: integer
hot_score: integer
confidence_score: integer
public_created_at: string
summary: string
video_url: string
geozone_id: integer
retired_at: string
retired_reason: string
retired_explanation: string
geozone: Geozone
comments: [Comment]
proposal_notifications: [ProposalNotification]
public_author: User
votes_for: [Vote]
tags: [Tag]
Comment:
fields:
id: integer
commentable_id: integer
commentable_type: string
body: string
public_created_at: string
cached_votes_total: integer
cached_votes_up: integer
cached_votes_down: integer
ancestry: string
confidence_score: integer
public_author: User
votes_for: [Vote]
Geozone:
fields:
id: integer
name: string
ProposalNotification:
fields:
title: string
body: string
proposal_id: integer
public_created_at: string
proposal: Proposal
Tag:
fields:
id: integer
name: string
taggings_count: integer
kind: string
Vote:
fields:
votable_id: integer
votable_type: string
public_created_at: string
vote_flag: boolean

View File

@@ -64,18 +64,6 @@ module ActsAsTaggableOn
Tag.category.pluck(:name)
end
def self.graphql_field_name
:tag
end
def self.graphql_pluralized_field_name
:tags
end
def self.graphql_type_name
"Tag"
end
private
def custom_counter_field_name_for(taggable_type)

View File

@@ -1,3 +1,3 @@
get "/graphql", to: "graphql#query"
post "/graphql", to: "graphql#query"
post "/graphql", to: "graphql#execute"
get "/graphql", to: "graphql#execute"
mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql"

View File

@@ -1,85 +0,0 @@
require "graphql"
module GraphQL
class ApiTypesCreator
SCALAR_TYPES = {
integer: GraphQL::INT_TYPE,
boolean: GraphQL::BOOLEAN_TYPE,
float: GraphQL::FLOAT_TYPE,
double: GraphQL::FLOAT_TYPE,
string: GraphQL::STRING_TYPE
}.freeze
def self.create
created_types = {}
api_types_definitions.each do |model, info|
create_type(model, info[:fields], created_types)
end
created_types
end
def self.api_types_definitions
@api_types_definitions ||= parse_api_config_file(YAML.load_file(Rails.root.join("config/api.yml")))
end
def self.type_kind(type)
if SCALAR_TYPES[type]
:scalar
elsif type.class == Class
:singular_association
elsif type.class == Array
:multiple_association
end
end
def self.create_type(model, fields, created_types)
created_types[model] = GraphQL::ObjectType.define do
name model.graphql_type_name
description model.graphql_type_description
# Make a field for each column, association or method
fields.each do |field_name, field_type|
case ApiTypesCreator.type_kind(field_type)
when :scalar
field(field_name, SCALAR_TYPES[field_type], model.human_attribute_name(field_name))
when :singular_association
field(field_name, -> { created_types[field_type] }) do
resolve ->(object, arguments, context) do
association_target = object.send(field_name)
association_target.present? ? field_type.public_for_api.find_by(id: association_target.id) : nil
end
end
when :multiple_association
field_type = field_type.first
connection(field_name, -> { created_types[field_type].connection_type }, max_page_size: 50, complexity: 1000) do
resolve ->(object, arguments, context) { object.send(field_name).public_for_api }
end
end
end
end
end
def self.parse_api_config_file(file)
api_type_definitions = {}
file.each do |api_type_model, api_type_info|
model = api_type_model.constantize
fields = {}
api_type_info["fields"].each do |field_name, field_type|
if field_type.is_a?(Array) # paginated association
fields[field_name.to_sym] = [field_type.first.constantize]
elsif SCALAR_TYPES[field_type.to_sym]
fields[field_name.to_sym] = field_type.to_sym
else # simple association
fields[field_name.to_sym] = field_type.constantize
end
end
api_type_definitions[model] = { fields: fields }
end
api_type_definitions
end
end
end

View File

@@ -1,28 +0,0 @@
require "graphql"
module GraphQL
class QueryTypeCreator
def self.create(api_types)
GraphQL::ObjectType.define do
name "QueryType"
description "The root query for the schema"
api_types.each do |model, created_type|
if created_type.fields["id"]
field model.graphql_field_name do
type created_type
description model.graphql_field_description
argument :id, !types.ID
resolve ->(object, arguments, context) { model.public_for_api.find_by(id: arguments["id"]) }
end
end
connection(model.graphql_pluralized_field_name, created_type.connection_type, max_page_size: 50, complexity: 1000) do
description model.graphql_pluralized_field_description
resolve ->(object, arguments, context) { model.public_for_api }
end
end
end
end
end
end

View File

@@ -1,58 +0,0 @@
require "rails_helper"
describe GraphQL::ApiTypesCreator do
let(:created_types) { {} }
describe "::create_type" do
it "creates fields for Int attributes" do
debate_type = GraphQL::ApiTypesCreator.create_type(Debate, { id: :integer }, created_types)
created_field = debate_type.fields["id"]
expect(created_field).to be_a(GraphQL::Field)
expect(created_field.type).to be_a(GraphQL::ScalarType)
expect(created_field.type.name).to eq("Int")
end
it "creates fields for String attributes" do
debate_type = GraphQL::ApiTypesCreator.create_type(Debate, { title: :string }, created_types)
created_field = debate_type.fields["title"]
expect(created_field).to be_a(GraphQL::Field)
expect(created_field.type).to be_a(GraphQL::ScalarType)
expect(created_field.type.name).to eq("String")
end
it "creates connections for :belongs_to associations" do
user_type = GraphQL::ApiTypesCreator.create_type(User, { id: :integer }, created_types)
debate_type = GraphQL::ApiTypesCreator.create_type(Debate, { author: User }, created_types)
connection = debate_type.fields["author"]
expect(connection).to be_a(GraphQL::Field)
expect(connection.type).to eq(user_type)
expect(connection.name).to eq("author")
end
it "creates connections for :has_one associations" do
user_type = GraphQL::ApiTypesCreator.create_type(User, { organization: Organization }, created_types)
organization_type = GraphQL::ApiTypesCreator.create_type(Organization, { id: :integer }, created_types)
connection = user_type.fields["organization"]
expect(connection).to be_a(GraphQL::Field)
expect(connection.type).to eq(organization_type)
expect(connection.name).to eq("organization")
end
it "creates connections for :has_many associations" do
comment_type = GraphQL::ApiTypesCreator.create_type(Comment, { id: :integer }, created_types)
debate_type = GraphQL::ApiTypesCreator.create_type(Debate, { comments: [Comment] }, created_types)
connection = debate_type.fields["comments"]
expect(connection).to be_a(GraphQL::Field)
expect(connection.type).to eq(comment_type.connection_type)
expect(connection.name).to eq("comments")
end
end
end

View File

@@ -1,37 +0,0 @@
require "rails_helper"
describe GraphQL::QueryTypeCreator do
before do
allow(GraphQL::ApiTypesCreator).to receive(:api_types_definitions).and_return(
{
ProposalNotification => { fields: { title: :string }},
Proposal => { fields: { id: :integer, title: :string }}
}
)
end
let(:api_types) { GraphQL::ApiTypesCreator.create }
describe "::create" do
let(:query_type) { GraphQL::QueryTypeCreator.create(api_types) }
it "creates a QueryType with fields to retrieve single objects whose model fields included an ID" do
field = query_type.fields["proposal"]
expect(field).to be_a(GraphQL::Field)
expect(field.type).to eq(api_types[Proposal])
expect(field.name).to eq("proposal")
end
it "creates a QueryType without fields to retrieve single objects whose model fields did not include an ID" do
expect(query_type.fields["proposal_notification"]).to be_nil
end
it "creates a QueryType with connections to retrieve collections of objects" do
connection = query_type.fields["proposals"]
expect(connection).to be_a(GraphQL::Field)
expect(connection.type).to eq(api_types[Proposal].connection_type)
expect(connection.name).to eq("proposals")
end
end
end

View File

@@ -1,12 +1,5 @@
require "rails_helper"
api_types = GraphQL::ApiTypesCreator.create
query_type = GraphQL::QueryTypeCreator.create(api_types)
ConsulSchema = GraphQL::Schema.define do
query query_type
max_depth 12
end
def execute(query_string, context = {}, variables = {})
ConsulSchema.execute(query_string, context: context, variables: variables)
end
@@ -40,8 +33,8 @@ describe "Consul Schema" do
let(:proposal) { create(:proposal, author: user) }
it "returns fields of Int type" do
response = execute("{ proposal(id: #{proposal.id}) { id } }")
expect(dig(response, "data.proposal.id")).to eq(proposal.id)
response = execute("{ proposal(id: #{proposal.id}) { cached_votes_up } }")
expect(dig(response, "data.proposal.cached_votes_up")).to eq(proposal.cached_votes_up)
end
it "returns fields of String type" do
@@ -373,6 +366,17 @@ describe "Consul Schema" do
expect(received_comments).not_to include(not_public_poll_comment.body)
end
it "only links public comments" do
user = create(:administrator).user
create(:comment, author: user, body: "Public")
create(:budget_investment_comment, author: user, valuation: true, body: "Valuation")
response = execute("{ user(id: #{user.id}) { public_comments { edges { node { body } } } } }")
received_comments = dig(response, "data.user.public_comments.edges")
expect(received_comments).to eq [{ "node" => { "body" => "Public" }}]
end
it "only returns date and hour for created_at" do
created_at = Time.zone.parse("2017-12-31 9:30:15")
create(:comment, created_at: created_at)