Here’s a series of my notes of working within a GraphQL application as I can think of them. This comes out of my work on Twist’s GraphQL API, and other GraphQL APIs that are deployed in production.
Overall sentiment is that GraphQL is an improvement over the classic REST approach because:
- It gives you clear types of fields
- You can choose which fields you wish to select
- You can choose to select from a single resource, or from multiple, disparate resources at the same time.
I like its interoperability between Ruby and JavaScript, with good tooling existing on both sides of that divide in the GraphQL Rubygem and the Apollo Client on the JavaScript side of things. Honorable mention to the GraphQL codegen library too, which provides the ability of generating TypeScript types from a GraphQL schema.
Schema definition
A schema can be defined in app/graphql
in a Rails application (since its application code), or a directory of your choosing in any other Ruby project.
I’d recommend disabling the introspection endpoints here so that 3rd parties cannot find out that your API contains particular admin-only endpoints. I’d also recommend setting up a max_complexity
and a max_depth
value. This prevents API requests from recursively requesting data (think post -> comments -> post -> …), and from also building queries that might rate as “highly complex” database operations. You can read more about complexity and depth here.
class AppSchema < GraphQL::Schema
mutation(Types::MutationType)
query(Types::QueryType)
disable_introspection_entry_points unless Rails.env.development?
max_complexity 200
max_depth 20
end
Schema dumping from Ruby
On that topic, the Ruby library provides the ability to dump the schema out with a custom Rake task (that I’ve put in lib/tasks/graphql.rake
)
require "graphql/rake_task"
GraphQL::RakeTask.new(
schema_name: "AppSchema",
directory: "./app/javascript/graphql",
dependencies: [:environment]
)
Running this Rake task:
bundle exec rake graphql:schema:dump
Will generate two files, a schema.json
and a schema.graphql
, which are both representations of the shape of the GraphQL API. Different tools (such as GraphQL codegen) can then use this schema to work with the GraphQL API.
Queries and Resolvers
The GraphQL Ruby library recommends defining the fields and their resolvers within the same class:
module Twist
module Web
module GraphQL
class QueryType < ::GraphQL::Schema::Object
field :books, [Types::Book], null: false
def books
...
end
end
end
end
end
I feel like this gets messy particularly quickly if your types have a large (> 5) amount of fields.
For top level fields like this, I would recommend defining separate resolver classes:
field :books, [Types::Book], null: false, resolver: Resolvers::Books
module Twist
module Web
module GraphQL
module Resolvers
class Books < Resolver
def resolve
...
end
end
end
end
end
end
This allows for you to have potentially complex logic for resolution separate from the field definitions, allowing you to read what fields are defined by looking at the type, rather than reading what the fields are and how they’re also implemented.
If we had a resolver for a book chapter, then I’d put that class under Resolvers::Books::Chapters
to indicate that it’s not resolving all chapters, but rather chapters for a particular book.
Mutations
Along similar lines to queries and resolvers, I also suggest using separate classes for mutations, namespacing them down the lines of the particular context of the application (Mutations::Users::Login
), or at least along the lines of the resource thats undergoing mutation:
field :add_comment, mutation: Mutations::Comments::Add
It’s worth noting that if your BaseMutation
class inherits from GraphQL::Schema::RelayClassicMutation
, that these mutations will have an input
argument defined for them. In the GraphQL documentation, this would appear as:
addComment(input: AddInput!): AddPayload
If you have a separate class called Post::Add
, it will have an identical AddInput
type and AddPayload
defined. GraphQL supports only one type of each different name, and so we must differentiate these. To do this, inside the mutation class we define its graphql_name
:
module Mutations
class Comments::Add < BaseMutation
graphql_name "AddComment"
end
end
This will rename both the input and payload types:
addComment(input: AddCommentInput!): AddCommentPayload
Union types
Occassionally, it can be helpful to return one or another type from a GraphQL query or a mutation. For this, GraphQL has union types.
module Twist
module Web
module GraphQL
module Types
class BookPermissionCheckResult < BaseUnion
description "The result from attempting a login"
possible_types Types::Book, Types::PermissionDenied
def self.resolve_type(object, _context)
if object.is_a?(Twist::Entities::Book)
Types::Book
else
Types::PermissionDenied
end
end
end
end
end
end
end
This type is used in the book
field:
field :book, Types::BookPermissionCheckResult, null: false, resolver: Resolvers::Book
If the resolver returns a Twist::Entities::Book
instance, then this union type will use the GraphQL class Types::Book
to resolve this field. Otherwise, it uses Types::PermissionDenied
.
In the client-side GraphQL query, utilising these union types looks like this:
query {
book(permalink: "exploding-rails") {
__typename
... on Book {
id
title
defaultBranch {
name
chapters(part: FRONTMATTER) {
...chapterFields
}
}
}
... on PermissionDenied {
error
}
}
}
The query uses the GraphQL __typename
to return the type of the book
field. We can then read this type on the client side to determine how to act (to show a book, or not). The fields selected within both branches of this union allow us to display information about a book if the query has gone through successfully, without having to first check for permission, and then querying for a book.
The resolve_type
method from union classes can also return an array:
def self.resolve_type(object, _context)
if object.success?
[Types::BookType, object.success]
else
[Types::PermissionDenied, object.failure]
end
end
This is helpful if we wish to do something with the object being resolved. In this case, we’re unwrapping that object
from a Dry::Result
wrapping. If we did not do this unwrapping, then the BookType
type would not be able to work on the object it receives, as the wrapped Dry::Result
object does not have the title
that Types::Book
would expect that object to have.
Authentication
To authenticate against the GraphQL API, I’d recommend supporting sessions as well as authentication by tokens. While requests may come into the application from the same domain, they also may not. Allowing that flexibility of your API to be queryable by a 3rd party from the outset (assuming they have the right token!) can only be a good thing. It will also allow you to make requests from within tests by providing a token.
You can do this with something like the following code:
def current_user
@current_user = super
@current_user ||= User.authenticate_by_token(request.authorization)
end
Testing
To test the GraphQL endpoints, I would recommend request specs over testing the schema itself by calling Schema.execute(...)
. This ensures that you can run tests against your API as close to how it will be used as possible.
To aid in this, I like adding a GraphqlHelpers
module with a little helper:
def graphql_request(query:, variables: {})
post "/graphql",
params: {
query: query,
variables: variables,
}.to_json,
headers: { Authorization: user.token, 'Content-Type': "application/json" }
end
You can then use this in a test:
query = %|
query {
book(permalink: "exploding-rails") {
title
}
}
|
json = graphql_request(query: query)
expect(json.dig("book", "title")).to eq("Exploding Rails")
Preventing N+1 queries
By default, GraphQL Ruby will perform N+1 queries if you write a query such as:
query {
users {
books {
chapters
}
}
}
This will make one query for all the users, N queries for all of those users’ books, and M queries for all of those users’ books’ chapters.
To prevent N+1 queries, I’d recommend relying on the GraphQL::Dataloader
features shown here. This will collect all the IDs for the relevant resources, and then perform one large fetch for each of the users, each of the users’ books, and each of the users’ books’ chapters, resulting in only 3 queries.