How to Implement a GraphQL API in Rails
GraphQL came out of Facebook a number of years ago as a way to solve a few different issues that typical RESTful APIs are prone to. One of those was the issue of under- or over-fetching data.
Under-fetching is when the client has to make multiple roundtrips to the server just to satisfy the data needs they have. For example, the first request is to get a book, and a follow-up request is to get the reviews for that book. Two roundtrips is costly, especially when dealing with mobile devices on suspect networks.
Over-fetching is when you only need specific data, such as the name + email of a user, but since the API doesn’t know when you need, it sends you additional information which will be ignored, such as address fields, photo, etc.
With GraphQL, you describe to the server exactly what you are looking for, no more, no less. A typical request might look like this, asking for some information about rental properties along with the name of the owner:
query { rentals { id beds owner { name } } }
The response from the server arrives as follows:
{ "data": { "rentals": [ { "id": "203", "beds": 2, "owner": { "name": "Berniece Anderson" } }, { "id": "202", "beds": 1, "owner": { "name": "Zola Hilll" } } ] } }
The request ends up looking remarkably similar to the response. We described the exact data we were looking for and that is how it arrived back to us. If you’re absolutely brand-new to GraphQL, I recommend the website https://www.howtographql.com, which provides great examples in a variety of frontend/backend technologies.
In this article, we will explore how to implement a GraphQL API in Rails, something that giants such as GitHub and Shopify are already using in production. The application we’ll be working with is available on GitHub.
Getting Started
We’ll start with a fresh Rails installation: rails new landbnb --database=postgresql --skip-test
.
We’ll be working with three models for this app:
- Rental: The house/apartment being rented
- User: The User which owns the Rental (owner) or books a Rental (guest)
- Booking: A User staying at a Rental for a specified period of time
Because the point of this article isn’t to cover DB migrations and model setup, please refer to the migrations and models provided in the GitHub repository. I have also created a seed file to provide us with some initial data to play around with. Simply run the command bundle exec rake db:seed
.
Installing GraphQL
Now it’s time to actually get to the GraphQL part of our Rails app.
Add the graphql gem to your Gemfile and then run the command rails generate graphql:install
. This will create a new app/graphql
folder, which is where we’ll spend the majority of our time. It has also added a route for us along with a new controller. Unlike typical Rails apps, we’ll almost never be working inside of the controller or the routes files.
NOTE: In the app/graphql/landbnb_schema.rb
file, comment out the mutation line until we have built mutations. It was giving me an error!
In GraphQL, there are three “root” types: – query: Fetching data… think of GET
requests – mutation: Modifying data… think of POST
or PUT
requests – subscription: Real-time updates… think of ActionCable or websockets.
Queries
We’ll begin by defining our first query to fetch all of the rentals:
# app/graphql/types/query_type.rb Types::QueryType = GraphQL::ObjectType.define do name "Query" field :rentals, !types[Types::RentalType] do resolve -> (obj, args, ctx) { Rental.all } end end
By defining a field
called rentals
, it has given us the ability to perform a query with the field rentals
:
query { rentals { id } }
If we explore the code a little more, the field is made up of two parts: a type and a resolver.
The type is the type of data this field will return. !types[Types::RentalType]
means that it will return a non-null value, which is an array of something called a Types::RentalType
. We’ll look at that in a second.
We’ve also passed a block that defines a resolver. A resolver is basically us telling our code how to fill out the data for the rentals
field. In this case, we’ll just include a naive Rental.all
command. Don’t worry about what obj, args, ctx
are, as we’ll explore them more later.
Next, we need to define what the Types::RentalType
is:
# app/graphql/types/rental_type.rb Types::RentalType = GraphQL::ObjectType.define do name 'Rental' field :id, !types.ID field :rental_type, !types.String field :accommodates, !types.Int # ... other fields ... field :postal_code, types.String field :owner, Types::UserType do resolve -> (obj, args, ctx) { obj.user } end field :bookings, !types[Types::BookingType] end
The above code reminds me a little of defining JSON serializers. The object
we are serializing in this case is an instance of the Rental
model, and we’re defining which fields are available to be queried (along with their types). The owner
is slightly different because we don’t actually have an owner field on the model. By providing a resolver, we can resolve owner
to the object’s user
field.
You can run this query by visiting http://localhost:3000/graphiql in the browser and using the GraphiQL tool, which allows you to perform GraphQL queries and explore the API.
We’ll have to create types for the User
and Booking
as well, but they look very similar.
Queries with arguments
What if we wanted to allow the user to provide additional data to the query they are making? For example, the ability to say how many rentals should be returned via a limit
argument. This is done when defining the rentals
field, which we’ll update to the code below:
# app/graphql/types/query_type.rb Types::QueryType = GraphQL::ObjectType.define do name "Query" field :rentals, !types[Types::RentalType] do argument :limit, types.Int, default_value: 20, prepare: -> (limit) { [limit, 30].min } resolve -> (obj, args, ctx) { Rental.limit(args[:limit]).order(id: :desc) } end end
What we have done is to state that the rentals
field can contain a limit
argument, which must be an integer. We also provided a default value and a “preparing” function to massage the argument a little bit before it is used.
You’ll notice that our resolve
lambda now takes advantage of the args
parameter to access the limit
argument. We can now perform the query like this:
query { rentals(limit: 5) { id } }
Mutations
So far, we have only queried the data, but it is time to modify it! We’ll do this by creating a mutation to allow the user to sign in. Our query will look like this:
mutation { signInUser(email: {email: "test6@email", password: "secret"}) { token user { id name email } } }
Note that the “root” type is now mutation
. We’ve provided some arguments to the signInUser
field and have specified that in return we want the token
(a JWT that we’ll generate) and a few fields from the user.
First, ensure that your mutation
line in the app/graphql/landbnb_schema.rb
file is uncommented if you had commented it out previously. Then we’ll add a signInUser
field to our mutation file:
# app/graphql/types/mutation_type.rb Types::MutationType = GraphQL::ObjectType.define do name "Mutation" field :signInUser, function: Mutations::SignInUser.new end
And finally, we’ll write the code to handle resolving that field, which will live in its own file making it easier to test in isolation.
# app/graphql/mutations/sign_in_user.rb class Mutations::SignInUser < GraphQL::Function # define the arguments this field will receive argument :email, !Types::AuthProviderEmailInput # define what this field will return type Types::AuthenticateType # resolve the field's response def call(obj, args, ctx) input = args[:email] return unless input user = User.find_by(email: input[:email]) return unless user return unless user.authenticate(input[:password]) OpenStruct.new({ token: AuthToken.token(user), user: user }) end end
The AuthToken
class is a small PORO that I’ve put inside of the models folder. It uses the json_web_token
gem.
# app/models/auth_token.rb class AuthToken def self.key Rails.application.secrets.secret_key_base end def self.token(user) payload = {user_id: user.id} JsonWebToken.sign(payload, key: key) end def self.verify(token) result = JsonWebToken.verify(token, key: key) return nil if result[:error] User.find_by(id: result[:ok][:user_id]) end end
Authentication
Now that we’ve provided the token in response to the signInUser
mutation, we’ll expect that the token is passed in a header for subsequent requests.
With GraphiQL, you can define headers sent automatically with each request in the config/initializers/graphiql.rb
file (remember, this is for development only). I’ve used the dotenv
gem to store the JWT_TOKEN
during development.
if Rails.env.development? GraphiQL::Rails.config.headers['Authorization'] = -> (_ctx) { "bearer #{ENV['JWT_TOKEN']}" } end
We’ll now need to modify the controller to correctly pass the current_user
in as the context to our GraphQL code.
# app/controllers/graphql_controller.rb def execute # ... context = { current_user: current_user } #... end private def current_user return nil if request.headers['Authorization'].blank? token = request.headers['Authorization'].split(' ').last return nil if token.blank? AuthToken.verify(token) end
If we look at our bookRental
mutation, we can now grab the current user using this Authorization token. First, add the bookRental
field to the mutations file: field :bookRental, function: Mutations::BookRental.new
. We’ll now take a look at the actual mutation code:
# app/graphql/mutations/book_rental.rb class Mutations::BookRental < GraphQL::Function # define the required input arguments for this mutation argument :rental_id, !types.Int argument :start_date, !types.String argument :stop_date, !types.String argument :guests, !types.Int # define what the return type will be type Types::BookingType # resolve the field, perfoming the mutation and its response def call(obj, args, ctx) # Raise an exception if no user is present if ctx[:current_user].blank? raise GraphQL::ExecutionError.new("Authentication required") end rental = Rental.find(args[:rental_id]) booking = rental.bookings.create!( user: ctx[:current_user], start_date: args[:start_date], stop_date: args[:stop_date], guests: args[:guests] ) booking rescue ActiveRecord::RecordNotFound => e GraphQL::ExecutionError.new("No Rental with ID #{args[:rental_id]} found.") rescue ActiveRecord::RecordInvalid => e GraphQL::ExecutionError.new("Invalid input: #{e.record.errors.full_messages.join(', ')}") end end
Notice that we also handled errors for when the Rental ID was invalid or there were validation errors with the booking (for example, missing information or invalid booking dates).
Conclusion
What we’ve looked at is how to get up and running with GraphQL in Rails. We’ve defined queries, mutations, and a number of different types. We’ve also learned how to provide arguments to fields and how to authenticate a user using JSON Web Tokens.
In my next article, we’ll look at how to guard our application from a few potential performance threats.
Reference: | How to Implement a GraphQL API in Rails from our WCG partner Leigh Halliday at the Codeship Blog blog. |