Add new GraphQL types, schema (with fields) & base mutation

The current consul GraphQL API has two problems.

1) It uses some unnecessary complicated magic to automatically create
   the GraphQL types and querys using an `api.yml` file. This approach
   is over-engineered, complex and has no benefits. It's just harder to
   understand the code for people which are not familiar with the
   project (like me, lol).

2) It uses a deprecated DSL [1] that is soon going to be removed from
   `graphql-ruby` completely. We are already seeing deprecation warning
   because of this (see References).

There was one problem. I wanted to create the API so that it is fully
backwards compatible with the old one, BUT the old one uses field names
which are directly derived from the ruby code, which results in
snake_case field names - not the GraphQL way. When I'm using the
graphql-ruby Class-based syntax, it automatically creates the fields in
camelCase, which breaks backwards-compatibility.

So I've added deprecated snake_case field names to keep it
backwards-compatible.

[1] https://graphql-ruby.org/schema/class_based_api.html
This commit is contained in:
Finn Heemeyer
2022-01-05 13:23:11 +01:00
committed by Javi Martín
parent 5c6ab81c38
commit c984e666ff
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)