GraphQL is one of the many tools developers use to build modular APIs with a unique twist. Instead of following the conventional REST model, where it’s the API developer that decides what data is exposed in each endpoint, in a GraphQL API there’s typically only one endpoint, and it is the API consumer that decides what data it wants to fetch.
This tradeoff comes with extra security and performance concerns that we’ll cover later. In this blog post series, we look into the basics of GraphQL, how to use it in Rails, limitations, tooling, and more.
Let’s start with a quick overview of how GraphQL works.
Overview
GraphQL is a query language used in addition to an existing codebase. GraphQL APIs usually return data in JSON format, although this is not an hard requirement. It is completely agnostic to a database engine.
GraphQL is quite simple in its foundation: the basic building blocks are mutations and queries.
Users can think of mutations as a POST / PUT / DELETE request in the REST model: it is used to create, change, or delete data in a database engine. Likewise, users can think of queries as a GET request in the REST model, which are used to retrieve data.
GraphQL requests are typically sent to a single HTTP endpoint (usually called/graphql ). For the remainder of this article, we’ll assume that there’s a GraphQL API at https://greatbooks/graphql, an API to fetch data from a fictitious online book reviews. We’ll use this API to outline the key aspects of how GraphQL works.
Queries
Let’s walk through some examples of how to use queries to fetch data. Let’s say we want to fetch all the books available in the great books API:
query getBooks {
books {
id
title
synopse
author {
name
age
website
}
}
}This would return the following data:
[
{
"id": 1,
"title": "The Echoes of Amara",
"synpose": "In a world where memories can be extracted and sold, young archivist Amara discovers a hidden conspiracy that threatens the very fabric of society. As she delves deeper into the secrets of the memory trade, Amara must confront her past and decide whether to expose the truth, risking everything she holds dear. “The Echoes of Amara” is a gripping tale of identity, betrayal, and the power of memory.",
"author": {
"name": "Lyra Everhart",
"age": 32,
"website": "www.lyraeverhartwrites.com"
}
},
{
"id": 2,
"title": "Whispers of the Forgotten Forest",
"synpose": "Deep within the heart of the Mistwood lies a forgotten forest, rumored to be inhabited by mystical creatures and ancient spirits. When young botanist Elara stumbles upon an ancient map, she embarks on a journey to uncover the forest’s secrets. Along the way, she encounters a mysterious guardian who holds the key to saving the forest from an encroaching darkness. “Whispers of the Forgotten Forest” is a magical adventure of discovery and courage.",
"author": {
"name": "Rowan Greywood",
"age": 45,
"website": "www.rowangreywood.com"
}
},
{
"id": 3,
"title": "The Quantum Tapestry",
"synpose": "When aspiring writer Alice inherits her family’s ancestral home, Crescent Hall, she quickly discovers it’s plagued by restless spirits with unfinished business. As she delves into the house’s dark past, Alice uncovers a hidden diary revealing tragic events that link her ancestors to the spectral inhabitants. “The Haunting of Crescent Hall” is a chilling ghost story that weaves together the threads of love, loss, and redemption.",
"author": {
"name": "Jasper Thorn",
"age": 38,
"website": "www.jasperthornbooks.com"
}
},
{
"id": 4,
"title": "The Haunting of Crescent Hall",
"synpose": "In the vibrant coastal town of Starhaven, marine biologist Dr. Mira Lawson discovers an ancient underwater civilization. As Mira explores the depths, she uncovers a prophecy that foretells a cataclysmic event unless she can unite the land-dwellers and sea-dwellers. “Beneath the Starlit Seas” is an enchanting tale of adventure, unity, and the quest to save two worlds on the brink of destruction.",
"author": {
"name": "Isla Ravenwood",
"age": 29,
"website": "www.orionthornebooks.com"
}
},
{
"id": 5,
"title": "Beneath the Starlit Seas",
"snypose": "",
"author": {},
}
]A couple of important aspects:
- Note that the request payload is similar to a JSON object, but don’t be mistaken. This isn’t actually JSON. GraphQL uses its own query language and syntax rules. There aren’t commas separating each requested property from a book and the properties aren’t quoted, etc. It is easy to confuse this with JSON, so users should be careful. Users should think of the query syntax as the “shape” of the returned object (if the response format is in JSON, which typically it is).
- The name “getBooks” is not required. This is just the query name — it can be whatever we want. Furthermore, it can also be omitted. Naming the queries works as a way to document the request itself, and also for caching purposes in tools like Apollo Client.
- The book object can have more data to fetch, but since we didn’t explicitly ask for it, the data isn’t returned. Also, if, for some reason, a book has no known author, the “author” object would be empty (as seen in the book with id 5 in the example response).
This is great, but what if we want to fetch the details of a single book? In that case, we need to use queries with arguments:
query getBook {
book(id: 5) {
id
title
synpose
author {
name
}
}
}This would return:
{
"id": 5,
"title": "Beneath the Starlit Seas",
"snypose": "",
"author": {},
}What if we want to pass a variable to this query dynamically? For example, in the case above, the id is hard coded to be 5, but we would like to make it so that the id is dynamic and can be passed in at runtime. Imagine a front-end application where you choose the book first, and then the application shows you the reviews for that specific book. Here’s how that query would be written:
query getBookReviews($bookId: Int!) {
bookReviews(bookId: $bookId) {
rating
review
author {
name
}
}
}Then, depending on the GraphQL library used and the programming language, the variable $bookId would be passed to the query. For example, we can use the package apollo client in a React application to call this query like so:
import { gql, useQuery } from '@apollo/client';
const query = gql`
query getBookReviews($bookId: Int!) {
bookReviews(bookId: $bookId) {
rating
id
review
author {
name
}
}
}
`;
const { loading, error, data } = useQuery(query, {
variables: { bookId: 123 },
});
if (loading) return <p>Loading reviews ...</p>;
return data.bookReviews.map((review) => (
<div key={review.id}>
<span>Rating: {review.rating} / 5</span>
<span>Review: {review.review}</span>
<span>Author: {review.author.name}</span>
</div>
));Finally, let’s look over how to use fragments and aliases.
Aliases are a way to rename the fields returned in the JSON object. They are particularly useful for requesting the same field inside the same query multiple times but with different arguments.
For example, let’s say we want to fetch, inside the same query, the reviews for two different books (which is possible). One would assume this would be written like so:
query getMultipleBookReviews {
bookReviews(id: 1) {
rating
review
author {
name
}
}
bookReviews(id: 2) {
rating
review
author {
name
}
}
}Unfortunately, this doesn’t work. If this query were executed, an error like Fields ”bookReview” conflict because they have differing arguments. Use different aliases on the fields to fetch both if this was intentional would be thrown. This makes sense, given that this would return a JSON object with two keys with the same name, which is possible (the latest key value would override all the other ones), but it’s probably not what we want.
The easiest way to fix this is to use aliases, so that the reviews for the book with id 1 are stored in a different JSON key than the reviews of the book with id 2:
query getMultipleBookReviews {
firstBookReviews: bookReviews(id: 1) {
rating
review
author {
name
}
}
secondBookReviews: bookReviews(id: 2) {
rating
review
author {
name
}
}
}This would return a JSON object like:
{
"firstBookReviews": [
{
"rating": 5,
"review": "Awesome book!!",
"author": {
"name": "Foo"
}
}
],
"secondBookReviews": [
{
"rating": 1,
"review": "Terrible book!!",
"author": {
"name": "Bar"
}
}
]
}Looking at the queries above, it seems like the shape requested for each review is repeated. This can be improved by using fragments. Fragments allow re-using shapes of data in multiple queries.
query getMultipleBookReviews {
firstBookReviews: bookReviews(id: 1) {
...bookReviewShape
}
secondBookReviews: bookReviews(id: 2) {
...bookReviewShape
}
}
fragment bookReviewShape on BookReview {
rating
review
author {
name
}
}This would return the same data as the previous example. Fragments don’t need to be defined on the same file as the file where the query is defined. Tools like apollo-client allow for fragments to be imported from separate files.
Mutations
So far, we’ve seen how to extract data from the greatbooks GraphQL API. What if we want to create or change data? For example, our front-end application could allow a logged in user to create reviews for a book.
To create or edit data, we must use mutations. Mutations are similar to POST and PUT requests in the REST model. Here’s an example of how to create a review:
mutation newBookReview {
createBookReview(
input: {
bookId: 123,
authorId: 456,
rating: 3,
review: "This was a very exciting book to read!",
}
) {
bookReview {
rating
review
}
errors {
code
message
}
}There’s a lot going on in this mutation. Let’s break it down:
- Similar to a query, the mutation name (in this example, “newBookReview”) is optional and can be omitted. However, be careful not to confuse this with the actual mutation called “createBookReview”. This must be an actual exposed mutation operation in the schema of the API (more on this later).
- Notice how the mutation accepts arguments. In the example of this mutation, there’s a single argument called “input”, which is a hash with the details of the review. This is defined by the schema of the GraphQL API, which is generated by the server that hosts the API (more on this later).
- Mutations can, and should, return data in the response. In this case, this mutation returns two things: the review created (available in the property “bookReview”, which is similar to the queries we saw earlier) and any error that might have occurred while creating the review. Naturally, if there were errors in creating the review, the property “bookReview” would be empty. It is generally good practice to always fetch errors when calling a mutation. Your application should deal with these errors before anything else.
Here’s how the response would look like if there were no errors calling this mutation:
{
"bookReview": {
"rating": 3,
"review": "This was a very exciting book to read!",
"author": {
"name": "Foo bar"
}
},
"errors": []
}And here’s what the response would look like if there were errors:
{
"bookReview": {},
"errors": [
{
"code": 1003,
"message": "Book was not found"
}
]
}It is important to notice that, depending on the way the GraphQL server is built on the backend, both queries and mutations can modify/create data. Users are free to create a query that creates or changes data on the backend but, similarly to the REST model, it is a good practice to use queries only to fetch data and mutations to create/change/delete data.
There is, however, a key distinction between queries and mutations. While queries run in parallel on the server, mutations always run sequentially (one after the other).
Usage in Rails
To illustrate how to mount a GraphQL API on a rails application, let’s go ahead and create the greatbook reviews API.
(Note: I’m assuming the reader has a basic understanding of how a Rails application works, so I will be skipping some steps that would be required to set up a Rails app properly).
Let’s first create the Rails application and then set up the database:
rails new greatbooks -d postgresql
rails db:create db:migrate
Create the book, author, and review models:
rails g migration create_authors name:string age:integer website:string
rails g migration create_books title:string synopse:text author:references
rails g migration create_reviews rating:integer review:text author:references book:references
rails db:migrate
Let’s now seed the database with some reviews and books:
- Add the
fakergem to the gemfile - Alter the
seedsfile to have this content:
# Create authors
10.times do
Author.create(name: Faker::Book.author, age: rand(20..70), website: Faker::Internet.url)
end
# Create books
10.times do
Book.create(title: Faker::Book.title, synopse: Faker::Lorem.paragraph, author_id: rand(1..Author.last.id))
end
# Create reviews
100.times do
Review.create(review: Faker::Lorem.paragraph, rating: rand(1..5), book_id: rand(1..Book.last.id), author_id: rand(1..Author.last.id))
endNow, let’s add GraphQL to the project:
- Add the GraphQL gem to the gemfile
- Run the generator with
rails generate graphql:install - Run
bundle install
We are now ready to start creating the schema. The schema is the shape of all the objects, queries, and mutations that our GraphQL API exposes. The schema is required, otherwise no one will know how to call the GraphQL API and see what data it provides.
Let’s start by generating the schema types for books, authors, and reviews.
- Create a new file called
author_type.rbunder the foldertypes(which should already exist after running the generator) with the following contents:
# graphql/types/author_type.rb
module Types
class AuthorType < Types::BaseObject
field :id, ID, null: false
field :name, String
field :age, Integer
field :website, String
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
end
endNote that the types Graphql::Types::ISO8601DateTime and ID are standard types from GraphQL, we don’t need to define those.
2. Create a new file called book_type.rb in the same folder:
# graphql/types/book_type.rb
module Types
class BookType < Types::BaseObject
field :id, ID, null: false
field :title, String
field :synopse, Integer
field :author, Types::AuthorType, null: false
end
endNote that we don’t need to redefine the fields for the author type. We can simply import the Types::AuthorType type we created in the previous step.
3. Create a new file called review_type.rb in the same folder:
# graphql/types/review_type.rb
module Types
class ReviewType < Types::BaseObject
field :id, ID, null: false
field :review, String
field :rating, Integer
field :book, Types::BookType, null: false
field :author, Types::AuthorType, null: false
end
endWe now have all the needed types defined. Let’s start creating the queries and mutations.
To write queries and mutations, we need to create resolvers. Resolvers are similar to Rails controllers. They define how a particular GraphQL query is executed and the data it returns.
But before writing any resolvers, we need a query root. A query root is the entry point of our GraphQL schema. The query root file also defines all the queries that the GraphQL API supports. It’s kind of like the routes file in Rails.
Since we’re using the graphql-ruby gem, the command rails generate graphql:install has automatically generated the file for us (called query_type.rb). It should contain a TODO section with placeholder examples that should be replaced with the actual resolvers. Let’s add this to that file:
# graphql/types/query_type.rb
field :books, resolver: Resolvers::BooksResolverBy writing field :books , we’re saying that our GraphQL API responds to the query books. To implement the query, we need to create a resolver called BooksResolver inside a resolvers module (the resolvers folder should already exist after running the generator command mentioned above):
# graphql/resolvers/books_resolver.rb
module Resolvers
class BooksResolver < Resolvers::BaseResolver
type [Types::BookType], null: false
description "Returns all books"
def resolve
Book.all
end
end
endThis resolver accepts no arguments and returns all books in the system. We can run this query by running the following in the Rails console:
GreatbooksSchema.execute("{ books { id title } }").to_aWhich returns:
[["data",
{"books"=>
[{"id"=>"1", "title"=>"Dying of the Light"},
{"id"=>"2", "title"=>"Quo Vadis"},
{"id"=>"3", "title"=>"For a Breath I Tarry"},
{"id"=>"4", "title"=>"In a Dry Season"},
{"id"=>"5", "title"=>"The Moving Finger"},
{"id"=>"6", "title"=>"Consider the Lilies"},
{"id"=>"7", "title"=>"Edna O'Brien"},
{"id"=>"8", "title"=>"The Grapes of Wrath"},
{"id"=>"9", "title"=>"Cabbages and Kings"},
{"id"=>"10", "title"=>"Recalled to Life"}]}]]What if we want to test this endpoint in an HTTP client? For example, in postman? Well, simply fire up the Rails server with rails s, and call http://localhost:3000/graphql via POST with the following payload:
query getBooks {
books {
id
title
}
}If this query is run like this, there should be an error in the rails server:
ActionController::InvalidAuthenticityToken (Can’t verify CSRF token authenticity.)
To fix this issue, simply add the following line to the GraphqlController :
skip_before_action :verify_authenticity_token
Note: the graphql_controller.rb file was automatically generated by the gem’s generator. This is a regular rails controller, which has a single action called execute that executes the GraphQL query in the same way as we did above (but passes some more information like variables and context).
After changing the GraphQL controller, executing the query again should return:
{
"data": {
"books": [
{
"id": "1",
"title": "Dying of the Light"
},
{
"id": "2",
"title": "Quo Vadis"
},
{
"id": "3",
"title": "For a Breath I Tarry"
},
{
"id": "4",
"title": "In a Dry Season"
},
....
}Now that we know how to define queries, let’s fully write one of the queries we mentioned in the previous section: the query to fetch reviews for a single book.
Start by adding the proper field in the query_type.rb file:
# graphql/types/query_type.rb
field :books, resolver: Resolvers::BooksResolver
field :book_reviews, resolver: Resolvers::BookReviewsResolverLet’s create the resolver:
# graphql/resolvers/book_reviews_resolver.rb
module Resolvers
class BookReviewsResolver < Resolvers::BaseResolver
type [Types::ReviewType], null: false
description "Returns the reviews for a single book"
argument :book_id, ID, required: true
def resolve(book_id:)
book = Book.find_by(id: book_id)
raise ActiveRecord::RecordNotFound, "Book not found" if book.nil?
book.reviews
end
end
endNow let’s call the query:
# POST http://localhost:3000/graphql
query getBookReviews {
bookReviews(bookId: 1) {
rating
review
author {
website
age
name
}
}
}It should return something like:
{
"data": {
"bookReviews": [
{
"rating": 5,
"review": "Voluptate aliquid est. Enim possimus aliquid. Sunt corrupti totam.",
"author": {
"website": "http://bernier-monahan.example/bernard_hayes",
"age": 52,
"name": "Thao O'Kon"
}
},
{
"rating": 2,
"review": "Qui expedita qui. Vitae ab minima. Enim cum minima.",
"author": {
"website": "http://wiegand-funk.test/lachelle_terry",
"age": 29,
"name": "Codi Doyle"
}
},
{
"rating": 1,
"review": "Et rerum necessitatibus. Eveniet qui earum. Et soluta reiciendis.",
"author": {
"website": "http://tromp.test/artie",
"age": 37,
"name": "Gonzalo Yundt"
}
}
...
}So far, we’ve only asked for fields that come directly from a database table. What if we want to call a custom method of our models when building the return data for the query? For example, what if we want to return a field called number_of_reviews in the BookType?
This is easy. Let’s start by defining that method on the book model:
# app/models/book.rb
class Book < ApplicationRecord
# ...
def number_of_reviews
@number_of_reviews ||= reviews.size
end
# ...
endNow simply add a field called number_of_reviews to the BookType type:
# graphql/types/book_type.rb
module Types
class BookType < Types::BaseObject
field :id, ID, null: false
field :title, String
field :synopse, Integer
field :author, Types::AuthorType, null: false
field :number_of_reviews, Integer, null: false
end
endLet’s try calling the books query again:
query getBooks {
books {
id
title
numberOfReviews
}
}We now should have something like:
{
"data": {
"books": [
{
"id": "1",
"title": "Dying of the Light",
"numberOfReviews": 13
},
{
"id": "2",
"title": "Quo Vadis",
"numberOfReviews": 7
},
{
"id": "3",
"title": "For a Breath I Tarry",
"numberOfReviews": 11
},
....
}Notice how the field is defined in the underscore case (typical in Ruby code), but the field is extracted in the camel case. This is intended and is handled automatically by GraphQL.
Finally, let’s write a mutation to create a book review!
Creating mutations follows the same principles as a query, with some slight changes in syntax.
First, we need to define the mutation in the file mutation_type.rb. The reason mutations need to be defined in this field is because of the file greatbooks_schema.rb, we see these two lines:
# graphql/greatbooks_schema.rb
# ...
mutation(Types::MutationType)
query(Types::QueryType)
# ...So this means that queries must be written inside the class Types::QueryType, and mutations on the class Types::MutationType
So, let’s define the mutation:
# graphql/types/mutation_type.rb
field :create_book_review, mutation: Mutations::CreateBookReviewLet’s implement it:
# graphql/mutations/create_book_review.rb
module Mutations
class CreateBookReview < BaseMutation
description "Creates a new book_review"
field :book_review, Types::ReviewType, null: false
argument :review, String, required: true
argument :rating, Integer, required: true
argument :book_id, ID, required: true
argument :author_id, ID, required: true
def resolve(review:, rating:, book_id:, author_id:)
book = Book.find_by(id: book_id)
raise ActiveRecord::RecordNotFound, 'Book not found' unless book
author = Author.find_by(id: author_id)
raise ActiveRecord::RecordNotFound, 'Author not found' unless author
book_review = book.reviews.build(review: review, rating: rating, author: author)
raise GraphQL::ExecutionError.new "Error creating book_review", extensions: book_review.errors.to_hash unless book_review.save
{ book_review: }
end
end
endThere’s a lot going on here. Let’s break it down:
- Our mutation extends from the class
BaseMutation. The fileBaseMutationwas generated automatically from the GraphQL Rails generator. If we didn’t change anything, it should look like this:
# graphql/mutations/base_mutation.rb
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
endThis tells us that all mutations must follow the Relay Classic mutation specification, which is part of the Relay framework. It means that mutation formats must follow a specific convention. Read more about it here. We’ll also see an example of how to use it shortly.
2. Our mutation accepts four arguments, which are all required.
3. The mutation returns a single object of the type Types::ReviewType , which can’t be null.
4. The resolver implementation first tries to fetch the associated book and author from the database, attempts to create the review, and then builds the response object.
Let’s see it in action. Open Postman, and call POST http://localhost:3000/graphql with:
mutation newBookReview {
bookReviewCreate(input: { review: "This book didn't let me sleep!", rating: 10, bookId: 1, authorId: 1 }) {
bookReview {
id
rating
author {
id
website
}
book {
title
}
}
}
}This should return a response like:
{
"data": {
"bookReviewCreate": {
"bookReview": {
"id": "103",
"rating": 10,
"author": {
"id": "1",
"website": "http://wiegand-funk.test/lachelle_terry"
},
"book": {
"title": "Dying of the Light"
}
}
}
}
}Note: We’ll see how to handle errors in mutations and queries in the next blog post.
Considerations
When using GraphQL, users must be particularly thoughtful of the following:
- It’s extremely easy to write inefficient queries with GraphQL when using rails (and I would assume when using any other ORM as well). If the Rails code has N+1 queries in the relationships methods (
has_one :something), it’s going to be extremely easy to allow inefficient queries to come through from API consumers. If the schema allows it, a consumer only needs to ask for an additional field from an object, and the entire application can come crashing down. It is important to have performance metrics running on production to check which queries are slow and expensive. - In large applications, the entire schema may be slow to generate.
- Since there’s typically only one endpoint being called (
/graphql), it becomes much more difficult to track which resources are most requested by API consumers without actually analyzing each request payload, which in turn makes it difficult to check for critical API requests and to prioritize bug fixing and new features. Again, performance monitoring and metric reporting become much more important when using GraphQL, in my opinion.
In the next blog post, we dive deeper into queries, testing with RSpec, pagination, and error handling.