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.rbfile, 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
endBy 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]
endThe 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.
!Sign up for a free Codeship Account
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
endWhat 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
endThe 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
endAuthentication
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']}"
}
endWe'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)
endIf 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
endNotice 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.