TL;DR; GraphQL is a good way of making your API more flexible and less resource consuming. But if you think that type-definition is cumbersome then read on. With the modules we provide you'll be able to expose fully functional resources with one line of code.
For those who haven't followed the GraphQL trend launched by Facebook, it's a fancy way of mixing API and SQL concepts together.
Instead of making calls to a properly structured endpoint with parameters like with REST APIs, GraphQL makes you build syntactic queries that you send to one endpoint.
The benefit of GraphQL? A properly defined standard for:
- Making multiple queries as once
- Forcing consumers to select the fields they need
- Fetching related resources as part of parent resources
- Paginating resources and sub-resources (using relay-style pagination)
- Strongly-typing the resources you expose
- Documenting your API without the immediate need for a separate documentation website
Couldn't a REST API do the above? Of course it could. But GraphQL has defined a standard for all these and many clients are already out there providing out of the box functionalities for interacting with GraphQL APIs. So...why not give it a try?
If you need more convincing you can read GitHub's blog article explaining why they switched.
When it comes to implementing a GraphQL server in Rails, one can use the excellent GraphQL Ruby gem.
The gem provides all the foundations for building your API. But the implementation is still very much manual, with lots of boilerplate code to provide.
In this article I will guide you through the steps of bootstrapping GraphQL Ruby then show you how - with a bit of introspection - you can easily expose your resources the Rails Way™ (= with one line of code).
First steps with graphql-ruby
Let's dive into graphql-ruby and see how we can go from zero to first query.
Installing graphql-ruby
First add the graphql gem to your Gemfile:
# GraphQL API functionalities
gem "graphql", "~> 1.12.12"
Then run the install generator:
rails generate graphql:install
The generator will create the GraphQL controller, setup the base types and update your routes.
That's it for the install part. Now let's see how we can expose resources to query.
Defining and exposing models
The first important file to look at is the Types::QueryType
file. This class defines all the attributes which can be queried on your GraphQL API.
For the purpose of demonstrating how records get exposed, let's generate a User and a Book model.
# Generate a basic user model
rails g model User email:string name:string
# Generate a basic book model with an ownership link to our user model
rails g model Book name:string pages:integer user:references
# Run the migrations
rake db:migrate
We'll expose these two classes for querying on our GraphQL API. To do so we need to define their type.
We'll start by defining a base type for common record attributes. These kind of base classes can help keep your type classes more focused.
# app/graphql/types/record_type.rb
# frozen_string_literal: true
module Types
# Define common attributes used by our records
module RecordType
include Types::BaseInterface
field :id, ID, null: false, description: 'The unique identifier of the resource.'
field :created_at, GraphQL::Types::ISO8601DateTime, null: false, description: 'The date and time that the resource was created.'
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false, description: 'The date and time that the resource was last updated.'
end
end
Then let's define GraphQL types for our models.
This is the User type:
# app/graphql/types/user_type.rb
# frozen_string_literal: true
module Types
class UserType < Types::BaseObject
implements Types::RecordType
description 'A user'
field :email, String, null: false, description: 'The email address of the user.'
field :name, String, null: false, description: 'The name of the user.'
end
end
This is the Book type. You'll notice that the user field reuses the User type.
# app/graphql/types/book_type.rb
# frozen_string_literal: true
module Types
class BookType < Types::BaseObject
implements Types::RecordType
description 'A book'
field :name, String, null: false, description: 'The name of the book.'
field :pages, Integer, null: false, description: 'The number of pages in the book'
field :user, UserType, null: false, description: 'The owner of the book'
end
end
Now that we have defined our types we need to plug them to the GraphQL Query API. This plumbing happens in the Types::QueryType
class.
Here is the generated Types::QueryType
class that we have expanded a bit to expose our collections. We use connection_type
instead of arrays on the Book and User types so as to automatically benefit from relay-style pagination.
# app/graphql/types/query_type.rb
# frozen_string_literal: true
module Types
class QueryType < Types::BaseObject
# Add `node(id: ID!) and `nodes(ids: [ID!]!)`
include GraphQL::Types::Relay::HasNodeField
include GraphQL::Types::Relay::HasNodesField
#==============================
# Fields
#==============================
# TODO: Test field. remove me
field :test_field, String, null: false, description: "An example field added by the generator"
# Record fields
field :books, BookType.connection_type, null: false, description: "The list of books"
field :users, UserType.connection_type, null: false, description: "The list of users"
#==============================
# Field logic
#==============================
def test_field
"Hello World!"
end
def books
Book.order(:created_at)
end
def users
User.order(:created_at)
end
end
end
Let's see how we can use our API now.
Querying the GraphQL API
The easiest way to query your GraphQL API is to use GraphiQL.
Good news though, the GraphQL gem generator automatically adds the graphiql-rails
gem to your gemfile. After running bundle install
you should be able to access GraphiQL on http://localhost:3000/graphiql
You might encounter a precompilation error. In that case update your manifest.js and add the GraphiQL assets.
// app/assets/config/manifest.js
// GraphiQL assets
//= link graphiql/rails/application.css
//= link graphiql/rails/application.js
// Your assets
//= link_tree ../images
//= link_directory ../stylesheets .css
If you prefer, you can also install GraphiQL as a standalone app. See this link for more info.
When you open GraphiQL, the first thing you should look at is the docs
section. You'll notice that all your models and fields are properly documented there. That's neat.
Let's create some test records via the Rails console:
# Create users
u1 = User.create(email: "john.doe@example.net", name: "John Doe")
u2 = User.create(email: "fanny.blue@example.net", name: "Fanny Blue")
# Create books
Book.create(name: "The great story", pages: 100, user: u1)
Book.create(name: "The awesome tale", pages: 200, user: u2)
Cool. Now we can perform our query.
Note how GraphQL allows us to perform multiple queries at once. That's really sweet.
Adding filtering attributes to your collections
It would be nice to have filters on our collections. The gem allows us to do that via field block definitions.
Here is a concrete example of adding a filter on page size.
# app/graphql/types/query_type.rb
# frozen_string_literal: true
module Types
class QueryType < Types::BaseObject
# Add `node(id: ID!) and `nodes(ids: [ID!]!)`
include GraphQL::Types::Relay::HasNodeField
include GraphQL::Types::Relay::HasNodesField
#==============================
# Fields
#==============================
# TODO: Test field. remove me
field :test_field, String, null: false, description: "An example field added by the generator"
# Books
field :books, BookType.connection_type, null: false do
description "The list of books"
# We define a filter argument on the collection attribute
argument :size_greater_than, Integer, required: false
end
# Users
field :users, UserType.connection_type, null: false, description: "The list of users"
#==============================
# Field logic
#==============================
def test_field
"Hello World!"
end
# The filter argument is passed to our method and conditionally
# used to refine the query scope.
def books(size_greater_than: nil)
rel = Book.order(:created_at)
rel = rel.where("pages >= ?", size_greater_than) if size_greater_than
rel
end
def users
User.order(:created_at)
end
end
end
Now you can easily filter on book size.
Nice! But I'm used to Rails where everything is inferred out of the box. Right now it looks quite cumbersome to define all these collections and filters. Isn't there a way to automatically generate those?
Of course there is. Time to use GraphQL custom resolvers with a bit of introspection!
Automatically defining resources and filters
In order to automatically build resources and their corresponding filters we'll need three things:
- A GraphQL helper to expose Active Record resources
- A custom resolver authorizing and querying our collections
- An Active Record helper to evaluate the query filters received from GraphQL.
The modules below are configured to use Pundit - if present - to scope access to records. Pundit is really just given as an example - any scoping framework would work, even custom policy classes.
Active Record query helpers
Let's start with the Active Record helper.
Add the following concern to your application. This concern allows collections to be filtered using underscore notation (e.g. created_at_gte for created_at >=) and sorting using dot notation (e.g. created_at.desc).
# app/models/concerns/graphql_query_scopes.rb
# frozen_string_literal: true
module GraphqlQueryScopes
extend ActiveSupport::Concern
# List of SQL operators supported by the with_api_filters scope
SQL_OPERATORS = {
eq: '= ?',
gt: '> ?',
gte: '>= ?',
lt: '< ?',
lte: '<= ?',
in: 'IN (?)',
nin: 'NOT IN (?)'
}.freeze
class_methods do
# If you use Postgres or any database storing date with millisecond precision
# then you might want to uncomment the body of this method.
#
# Millisecond precision makes timestamp equality and less than filters almost
# useless.
#
# Format field for SQL queries. Truncate dates to second precision.
# Used to build filtering queries based on attributes coming from the API.
def loose_precision_field_wrapper(field)
"#{table_name}.#{field}"
# if columns_hash[field.to_s].type == :datetime
# "date_trunc('second', #{table_name}.#{field})"
# else
# "#{table_name}.#{field}"
# end
end
end
included do
# Sort by created_at to have consistent pagination.
# This is particularly important when using UUID for IDs
default_scope { order(created_at: :asc, id: :asc) }
# This scopes aims at being overriden in children models
# This scope should typically specify eager loaded associations
# e.g. scope :graphql_scope { includes(:owner, :team) }
scope :graphql_scope, -> { all }
# Allow sorting using a 'dot' syntax (e.g. name.asc).
# Supports underscore and camelized attributes.
# This scope is typically used on the API
scope :with_sorting, lambda { |sort_by|
return all if sort_by.blank?
# Extract attributes
sort_attr, sort_dir = sort_by.split('.')
# Format attributes
sort_attr = sort_attr.underscore
sort_dir = 'asc' unless %w[asc desc].include?(sort_dir)
# Order scope or return self if the attribute does not exist
column_names.include?(sort_attr) ? unscope(:order).order(sort_attr => sort_dir) : all
}
# Allow filtering using attribute-level operators coming from the API.
# E.g.
# - created_at_gte => created_at greater than or equal to value
# - id_in => ID in list of values
#
# The list of operators is:
# *_gt => strictly greater than
# *_gte => greater than or equal
# *_lt => strictly less than
# *_lte => less than or equal
# *_in => value in array
# *_nin => value not in array
scope :with_api_filters, lambda { |args_hash|
# Build a SQL fragment for each argument
# Array is first build as [['table.field1 > ?', 123], [['table.field2 < ?', 400]]]
# then transposed into [['table.field1 > ?', 'table.field2 < ?'], [[123, 400]]]
sql_fragments, values = args_hash.map do |k, v|
# Capture the field and the operator
if column_names.include?(k.to_s)
field = k
operator = :eq
else
field, _, operator = k.to_s.rpartition('_')
end
# Sanitize the field and operator
raise ActiveRecord::StatementInvalid, "invalid operator #{k}" unless column_names.include?(field.to_s) && SQL_OPERATORS[operator.to_sym]
# Build SQL fragment
field_fragment = "#{loose_precision_field_wrapper(field)} #{SQL_OPERATORS[operator.to_sym]}"
# Return fragment and value
[field_fragment, v]
end.compact.transpose
# Combine regular args and SQL fragments to form the final scope
where(Array(sql_fragments).join(' AND '), *values)
}
end
end
Use this concern in your ApplicationRecord base class.
# app/models/application_record.rb
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
include GraphqlQueryScopes
self.abstract_class = true
end
Great! Now you can filter and sort records this way:
> Book.with_api_filters(pages_gte: 130).with_sorting('created.asc')
# Book Load (0.4ms) SELECT "books".* FROM "books" WHERE (books.pages >= 130) ORDER BY "books"."created_at" ASC, "books"."id" ASC LIMIT ? [["LIMIT", 11]]
#=> #<ActiveRecord::Relation [#<Book id: 2, name: "The awesome tale", pages: 200, user_id: 2, created_at: "2021-06-07 12:50:22.002122000 +0000", updated_at: "2021-06-07 12:50:22.002122000 +0000">]>
The concern also defines a default graphql_scope
, which is used by our resolvers. This scope can be overridden on each model to define API-specific eager loading strategies.
Here is an example with our book model.
# app/models/book.rb
# frozen_string_literal: true
class Book < ApplicationRecord
belongs_to :user
# Always eager load the associated user when books
# get queried on the API.
scope :graphql_scope, -> { eager_load(:user) }
end
GraphQL custom resolvers for collection and find queries
Now let's add a custom resolver to dynamically support our collections and corresponding filters. The resolver looks at all the fields defined on the model type and automatically generate filters for fields which are database queriable.
# app/graphql/resolvers/collection_query.rb
# frozen_string_literal: true
module Resolvers
# Parameterized Class used to generate resolvers finding multiple records via
# filtering attributes
#
# Example:
# Generate resolver for Types::MyClassType which is assumed to use the 'MyClass'
# ActiveRecord model under the hood:
# field :my_class, resolver: CollectionQuery.for(Types::MyClassType)
#
# Generate resolver for an association where the association name can be inferred from
# the type class
# field :posts, resolver: CollectionQuery.for(Types::PostType)
#
# Generate resolver for an association where the association cannot be inferred
# from the type class passed to the resolver
# field :published_posts, resolver: CollectionQuery.for(Types::MyClassType, relation: :published_posts)
#
class CollectionQuery < GraphQL::Schema::Resolver
# Class insteance variables that can be inherited by child classes
class_attribute :base_type, :resolver_opts
#---------------------------------------
# Constants
#---------------------------------------
# Define the operators accepted for each field type
FILTERING_OPERATORS = {
GraphQL::Types::ID => %i[in nin],
GraphQL::Types::String => %i[in nin],
GraphQL::Schema::Enum => %i[in nin],
GraphQL::Types::ISO8601DateTime => %i[gt gte lt lte in nin],
GraphQL::Types::Float => %i[gt gte lt lte in nin],
GraphQL::Types::Int => %i[gt gte lt lte in nin]
}.freeze
#---------------------------------------
# Class Methods
#---------------------------------------
# Return a child resolver class configured for the specified entity type
def self.for(entity_type, **args)
Class.new(self).setup(entity_type, args)
end
# Setup method used to configure the class
def self.setup(entity_type, **args)
# Configure class
use_base_type entity_type
use_resolver_opts args
# Set resolver type
type [entity_type], null: false
# Define each entity field as a filtering argument
filter_fields.each do |field_name, field_type|
argument field_name, field_type, required: false
end
# Sort field
argument :sort_by, String, required: false, description: 'Use dot notation to sort by a specific field. E.g. `createdAt.asc` or `createdAt.desc`.'
# Return class for chaining
self
end
# Set the base entity type
def self.use_base_type(type_klass = nil)
self.base_type = type_klass
end
# Set the resolver options
def self.use_resolver_opts(opts = nil)
self.resolver_opts = HashWithIndifferentAccess.new(opts)
end
#
# Return all base fields that can be used to generate filters
#
# @return [Hash] A hash of Field Name => GraphQL Field Type
#
def self.queriable_fields
native_queriable_fields.merge(association_queriable_fields)
end
#
# Return the list of native fields that can be used for filtering
#
# @return [Hash] A hash of field name => field type
#
def self.native_queriable_fields
base_type
.fields
.select { |k, _v| model_klass.column_names.include?(k.to_s.underscore) }
.select { |_k, v| v.type.unwrap.kind.input? && !v.type.list? }
.map { |k, v| [k, v.type.unwrap] }
.to_h
end
#
# Return the list of belongs_to fields that can be used for filtering
#
# @return [Hash] A hash of field name => field type
#
def self.association_queriable_fields
base_type
.fields
.values
.select { |v| v.type.unwrap.kind.object? }
.map { |v| model_klass.reflect_on_all_associations(:belongs_to).find { |e| e.name.to_s == v.name.to_s } }
.compact
.map { |e| [e.foreign_key, GraphQL::Types::ID] }
.to_h
end
# Return the list of fields accepted as filters (including operators)
def self.filter_fields
# Used queriable fields as equality filters
equality_fields = queriable_fields
# For each queriable field, find the list of operators applicable for the field class
operator_fields = equality_fields.map do |field_name, field_type|
# Find applicable operators by looking up the field type ancestors
operators = FILTERING_OPERATORS.find { |klass, _| field_type <= klass }&.last
next unless operators
# Generate all operator fields
operators.map do |o|
arg_type = %i[in nin].include?(o) ? [field_type] : field_type
["#{field_name.underscore}_#{o}".to_sym, arg_type]
end
end.compact.flatten(1).to_h
# Return equality and operator-based fields
equality_fields.merge(operator_fields)
end
# Return the underlying ActiveRecord model class
def self.model_klass
@model_klass ||= (resolver_opts[:model_name] || base_type.to_s.demodulize.gsub(/Type$/, '')).constantize
end
# Return the model Pundit Policy class
def self.pundit_scope_klass
@pundit_scope_klass ||= "#{model_klass}Policy::Scope".constantize
end
#---------------------------------------
# Instance Methods
#---------------------------------------
# Retrieve the current user from the GraphQL context.
# This current user must be injected in context inside the GraphqlController.
def current_user
@current_user ||= context[:current_user]
end
# Reject request if the user is not authenticated
def authorized?(**args)
super && (!defined?(Pundit) || current_user || raise(Pundit::NotAuthorizedError))
end
# Return the name of the association that should be defined on the parent
# object
def parent_association_name
self.class.resolver_opts[:relation] ||
self.class.model_klass.to_s.underscore.pluralize
end
# Return the instantiated resource scope via Pundit
# If a parent object is defined then it is assumed that the resolver is
# called within the context of an association
def pundit_scope
base_scope = object ? object.send(parent_association_name) : self.class.model_klass
# Enforce Pundit control if the gem is present
# This current user must be injected in context inside the GraphqlController.
if defined?(Pundit)
self.class.pundit_scope_klass.new(current_user, base_scope.graphql_scope).resolve
else
base_scope.graphql_scope
end
end
# Actual resolver method performing the ActiveRecord filtering query
#
# The resolver supports filtering via a range of operators:
# * => field equal to value
# *_gt => strictly greater than
# *_gte => greater than or equal
# *_lt => strictly less than
# *_lte => less than or equal
# *_in => value in array
# *_nin => value not in array
# > See ApplicationRecord#with_api_filters for the underlying filtering logic
#
# The resolver supports sorting via 'dot' syntax:
# sortBy: 'createdAt.desc'
# > See ApplicationRecord#with_sorting for the underlying sorting logic
#
def resolve(sort_by: nil, **args)
pundit_scope.with_api_filters(args).with_sorting(sort_by)
end
end
end
Let's also add a custom resolver to support fetching model by unique attribute. Any field your define as ID
on your model types will be exposed as a primary key for single record fetching purpose.
# app/graphql/resolvers/record_query.rb
# frozen_string_literal: true
module Resolvers
# Parameterized Class used to generate resolvers finding a single record
# using one of its ID keys
#
# Example:
# Generate resolver for Types::MyClassType which is assumed to use the 'MyClass'
# ActiveRecord model under the hood
# > RecordQuery.for(Types::MyClassType)
class RecordQuery < GraphQL::Schema::Resolver
# Class insteance variables that can be inherited by child classes
class_attribute :base_type, :resolver_opts
#---------------------------------------
# Class Methods
#---------------------------------------
# Return a child resolver class configured for the specified entity type
def self.for(entity_type, **args)
Class.new(self).setup(entity_type, args)
end
# Setup method used to configure the class
def self.setup(entity_type, **args)
# Set base type
use_base_type entity_type
use_resolver_opts args
# Set resolver type
type [entity_type], null: false
# Define argument for each primary key
id_fields.each do |f|
argument f.name, GraphQL::Types::ID, required: false
end
# Return class for chaining
self
end
# Set the base entity type
def self.use_base_type(type_klass = nil)
self.base_type = type_klass
end
# Set the resolver options
def self.use_resolver_opts(opts = nil)
self.resolver_opts = HashWithIndifferentAccess.new(opts)
end
# Return the list of ID fields
def self.id_fields
base_type.fields.values.select { |f| f.type.unwrap == GraphQL::Types::ID }
end
# Return the underlying ActiveRecord model class
def self.entity_klass
@entity_klass ||= base_type.to_s.demodulize.gsub(/Type$/, '').constantize
end
# Return the model Pundit Policy class
def self.pundit_scope_klass
@pundit_scope_klass ||= "#{entity_klass}Policy::Scope".constantize
end
#---------------------------------------
# Instance Methods
#---------------------------------------
# Retrieve the current user from the GraphQL context.
# This current user must be injected in context inside the GraphqlController.
def current_user
@current_user ||= context[:current_user]
end
# Reject request if the user is not authenticated
def authorized?(**args)
super && (!defined?(Pundit) || current_user || raise(Pundit::NotAuthorizedError))
end
# Return the name of the association that should be defined on the parent
# object
def parent_association_name
self.class.resolver_opts[:relation] ||
self.class.entity_klass.to_s.underscore.pluralize
end
# Return the instantiated resource scope via Pundit
# If a parent object is defined then it is assumed that the resolver is
# called within the context of an association
def pundit_scope
base_scope = object ? object.send(parent_association_name) : self.class.entity_klass
# Enforce Pundit control if the gem is present
# This current user must be injected in context inside the GraphqlController.
if defined?(Pundit)
self.class.pundit_scope_klass.new(current_user, base_scope.graphql_scope).resolve
else
base_scope.graphql_scope
end
end
# Actual resolver method performing the ActiveRecord find query
def resolve(**args)
# Avoid finding by nil value
return nil if (args_hash = args.compact).blank?
pundit_scope.find_by(args_hash)
end
end
end
In both resolvers I've made Pundit optional. But I strongly recommend using it or any similar framework. You should read the comments above each pundit_
method in the resolvers and adapt based on your needs.
For authorization purpose, you can inject a current_user
attribute inside the GraphQL context by modifying your GraphqlController
. Here is an example:
class GraphqlController < ApplicationController
# ...
def execute
variables = prepare_variables(params[:variables])
query = params[:query]
operation_name = params[:operationName]
# ==> Specify your GraphQL context here <==
context = {
current_user: current_user,
}
result = GraphqlRailsSchema.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
def current_user
# ... Devise or Custom logic for retrieving the current user
end
# ...
end
GraphQL base object to define resources and has_many
We have custom resolvers to handle the GraphQL query logic and model-level helpers to translate these into database-compatible filters. The last missing piece is a helper allowing us to declare our GraphQL resources.
To do this, add the following helper methods to your Types::BaseObject
class.
# app/graphql/types/base_object.rb
# 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
#--------------------------------------------
# Helpers
#--------------------------------------------
# Automatically generate find and list queries for a given resource
def self.resource(entity, **args)
entity_type = "Types::#{entity.to_s.singularize.classify}Type".constantize
record_resolver = args.delete(:record_resolver) || Resolvers::RecordQuery.for(entity_type)
collection_resolver = args.delete(:collection_resolver) || Resolvers::CollectionQuery.for(entity_type, args)
# Generate root field for entity find
field entity.to_s.singularize.to_sym, entity_type,
null: true,
resolver: record_resolver,
description: "Find #{entity.to_s.singularize.camelize}."
# Generate root field for entity list with filtering
field entity.to_s.pluralize.to_sym, entity_type.connection_type,
null: false,
resolver: collection_resolver,
description: "Query #{entity.to_s.pluralize.camelize} with filters."
end
# Define a has many relationship
# E.g. inferred type
# has_many :posts
#
# E.g. explicit type
# has_many :published_posts, type: Type::PostType
def self.has_many(rel_name, **args)
inferred_type = rel_name.to_s.singularize.camelize
model_klass_name = args.delete(:model_name) || inferred_type.classify
entity_type = args[:type] || "Types::#{inferred_type}Type".constantize
relation_name = args.delete(:relation) || rel_name
resolver_klass = args.delete(:resolver_class) || Resolvers::CollectionQuery
# Generate root field for entity list with filtering
field rel_name, entity_type.connection_type,
null: false,
resolver: resolver_klass.for(entity_type, relation: relation_name, model_name: model_klass_name),
description: "Query related #{rel_name.to_s.pluralize.camelize} with filters."
end
end
end
These helpers provide:
-
resource
: a helper to be used insideTypes::QueryType
to expose an Active Record model for collection querying and record fetching. -
has_many
: a way to define sub-collections on a type.
You can now rewrite your Types::QueryType
class the following way:
# app/graphql/types/query_type.rb
# frozen_string_literal: true
module Types
class QueryType < Types::BaseObject
# Add `node(id: ID!) and `nodes(ids: [ID!]!)`
include GraphQL::Types::Relay::HasNodeField
include GraphQL::Types::Relay::HasNodesField
resource :books
resource :users
end
end
Also let's add a has_many books
on our User model and type:
# Active Record Model
# app/models/user.rb
class User < ApplicationRecord
has_many :books
end
# GraphQL type
# app/graphql/types/user_type.rb
module Types
class UserType < Types::BaseObject
implements Types::RecordType
description 'A book'
field :email, String, null: false, description: 'The email address of the user.'
field :name, String, null: false, description: 'The name of the user.'
has_many :books
end
end
Querying our newly implemented resources
We're ready. Let's see how this works now.
As you can see on the right-hand side, all our collection filters are properly generated. We can also fetch records individually by ID field (id or any other ID field on the type). Finally, we can fetch sub-resources on parent records, such as user books.
Wrapping up
A bit of metaprogramming makes the whole GraphQL-Rails experience way easier than it was originally advertised. Now all we need to do is define model types and declare resources in our Types::QueryType
.
But there is more we can do. In the next episodes we'll see how to do similar things for mutations (create/update/delete) and subscriptions (via Pusher as a specific example).
Top comments (0)