Engineering September 17, 2024 15 min read

GraphQL | A Query Language for Your Rails API— Part 1

Photo by Glenn Carstens-Peters on Unsplash

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:

Code
query getBooks {
  books {
    id
    title
    synopse
    author {
      name
      age
      website
    }
  }
}

This would return the following data:

Code
[
  {
    "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:

  1. 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).
  2. 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.
  3. 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:

Code
query getBook {
  book(id: 5) {
    id
    title
    synpose
    author {
      name
    }
  }
}

This would return:

Code
{
    "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:

Code
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:

Code
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:

Code
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:

Code
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:

Code
{
  "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.

Code
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:

Code
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:

  1. 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).
  2. 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).
  3. 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:

Code
{
  "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:

Code
{
  "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:

  1. Add the faker gem to the gemfile
  2. Alter the seeds file to have this content:
Code
# 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))
end

Now, let’s add GraphQL to the project:

  1. Add the GraphQL gem to the gemfile
  2. Run the generator with rails generate graphql:install
  3. 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.

  1. Create a new file called author_type.rb under the folder types (which should already exist after running the generator) with the following contents:
Code
# 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
end

Note 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:

Code
# 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
end

Note 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:

Code
# 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
end

We 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:

Code
# graphql/types/query_type.rb

field :books, resolver: Resolvers::BooksResolver

By 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):

Code
# 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
end

This resolver accepts no arguments and returns all books in the system. We can run this query by running the following in the Rails console:

Code
GreatbooksSchema.execute("{ books { id title } }").to_a

Which returns:

Code
[["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:

Code
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:

Code
{
    "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:

Code
# graphql/types/query_type.rb

field :books, resolver: Resolvers::BooksResolver
field :book_reviews, resolver: Resolvers::BookReviewsResolver

Let’s create the resolver:

Code
# 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
end

Now let’s call the query:

Code
# POST http://localhost:3000/graphql

query getBookReviews {
    bookReviews(bookId: 1) {
        rating
        review
        author {
            website
            age
            name
        }
    }
}

It should return something like:

Code
{
    "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:

Code
# app/models/book.rb

class Book < ApplicationRecord
  # ...

  def number_of_reviews
    @number_of_reviews ||= reviews.size
  end

  # ...
end

Now simply add a field called number_of_reviews to the BookType type:

Code
# 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
end

Let’s try calling the books query again:

Code
query getBooks {
    books {
        id
        title
        numberOfReviews
    }
}

We now should have something like:

Code
{
    "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:

Code
# 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:

Code
# graphql/types/mutation_type.rb

field :create_book_review, mutation: Mutations::CreateBookReview

Let’s implement it:

Code
# 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
end

There’s a lot going on here. Let’s break it down:

  1. Our mutation extends from the class BaseMutation . The file BaseMutation was generated automatically from the GraphQL Rails generator. If we didn’t change anything, it should look like this:
Code
# 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
end

This 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:

Code
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:

Code
{
    "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:

  1. 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.
  2. In large applications, the entire schema may be slow to generate.
  3. 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.