Introduction
"Convention over configuration" is one of the reasons Rails became as popular as it did. Conventions allow developers to be productive without bike-shedding. However, introducing new concepts to Rails involves a period of experimentation during which there are no answers to troublesome questions.
Setting up a GraphQL server on Rails is one of those tasks.
This post presents a method for setting up a GraphQL server, while also tackling two issues that are generally glossed over by existing documentation surrounding the use of the graphql-ruby
gem:
- How do I handle authorization?
- How do I avoid N+1 queries?
Start with graphql-ruby
We'll be using the well-documented graphql-ruby
gem to get started with setting up a GraphQL server on Rails. If you're someone who's already familiar with graphql-ruby
, feel free to skip this section.
- Add
gem 'graphql-ruby', '~> 1.9'
to yourGemfile
. - Run
bundle install
on the command line. - Run the gem's installation generator:
rails generate graphql:install
.- If Rails complains that it can't find
graphql:install
, try again after stoppingspring
, withbin/spring stop
.
- If Rails complains that it can't find
- The previous step will have updated the
Gemfile
, so you'll need runbundle install
again.
At this point, graphql-ruby
is all set up. Let's take a quick look at what the gem added to our Rails application, so that the following steps are easier to understand.
app/
├─ config/routes.rb (updated to include /graphql, and /graphiql)
├─ controllers/
| └─ graphql_controller.rb (all requests are handled by the execute action)
└─ graphql/
├─ my_rails_app_schema.rb (the web of interconnected types starts here)
├─ types/
| ├─ query_type.rb (base type for all queries)
| ├─ mutation_type.rb (base type for all mutations)
| └─ base_*.rb (many other base types - object, enum, etc.)
└─ mutations/
└─ .keep (empty - we haven't made any mutations yet)
If we take a look inside the routes.rb
file, we can see that a new /graphql
path is handled by the GraphqlController#execute
method:
Notice how the incoming query is passed onto the schema (along with context) for execution. The schema defines only two things right now:
The schema says that mutations can be found in Types::MutationType
and queries in Types::QueryType
. If we take a look in either, we'll see a dummy field that we can play around with.
One thing that I glossed over in routes.rb
is that the gem also mounted the awesome GraphiQL app on the /graphiql
path. Visit the /graphiql
path in your browser, and try running the following query there:
query {
testField
}
You should get this response:
{
"data": {
"testField": "Hello World!"
}
}
There you go! Now that we're done with our whirlwind tour of the graphql-ruby
gem, let's start digging a bit deeper.
Here Be Dragons
While the graphql-ruby
gem has added a ton of functionality to our app, it doesn't really go into detail as to what the best practices are for actually using the gem. Specifically, as mentioned at the beginning of this guide, two issues that are glossed over are:
- How to handle authorization, and...
- How to efficiently query data.
Just like the official documentation about authorization in GraphQL, the gem's documentation also suggests pushing the responsibility for authorization into business logic, specifically into model methods that accept context
and decide what kind of relation or data is accessible for that user.
As for the potential for N+1 queries, it's pretty much ignored altogether - I'm guessing that you're expected to handle this on a case-by-case basis.
I'd like to suggest an alternative: new conventions.
Resolvers authorize and fetch data
Let's start by adding an ApplicationQuery
class that'll act as the base class for resolvers and mutators:
With that in place, we can start writing resolver objects that will help us retrieve properly authorized data for GraphQL queries. Let's start by creating a query that asks for a list of users:
Notice how the users
method simply hands over the responsibility for loading the data to a UsersResolver
object:
What you're looking at is the essence of this approach.
- All requests are individually authorized.
- There is an assumption that once a query is authorized, all data returned by the resolver (or mutator) can be accessed by the authenticated user.
- Avoid N+1-s by making sure that the resolver method
includes
all necessary data for the response.
Before we move onto mutators, let's also look at how we deal with queries that have arguments, using a variation of what we've done above:
Only a few things are different here:
-
args
is passed to the resolver in addition to context. - There's a
property :id
in the resolver class defining what data the query will work with. - Instead of a relation, the
user
method in the resolver returns aUser
object, since the type for the query is a single object.
Mutators authorize, modify, and supply a response
GraphQL mutations aren't really all that different from queries. Mutations are queries that are, by convention, allowed to modify data. And just like queries, they too can return structured data.
As with queries, let's start with a simple example that shows just how similar mutators are to resolvers.
Notice how there's a call to .valid?
before the .create_comment
is called. This triggers validations that can be configured in the mutator class:
Again, there's very little that's new here.
- Because
ApplicationQuery
includesActiveModel::Model
, we have access to all of the validation methods that we're familiar with. - The
property
helper simply combinesvalidates
andattr_accessor
into a single-step, and helps avoid bugs because the former depends on the latter. - We can either process the request in the mutator directly in the
create_comment
method, or pass it onto a service as shown in the example.
As with queries, there is an assumption that the create_comment
method will return an object that responds to the fields mentioned in the mutation class. In this case, that's id
, and as long as the service returns a Comment
object, everything should work as expected.
Create types for complex returns
While GraphQL unsubtly suggests the use of relations in your response types, there is no need to follow that pattern. Often, it's much more straight-forward to create a custom type that fits exactly the data that you want to return:
Here, the custom UpdatePostType
is used to compose exactly what the UI requires in this imaginary app, when a post is updated.
What are the advantages of this approach?
On the server-side:
- You have a self-documenting API. Testing it is a breeze thanks to GraphiQL.
- The Rails server will crash with a useful error message if your code ever disobeys the type specification.
- Pagination of resources is simple and straight-forward, thanks to built-in, well-thought-out conventions that cover a large variety of pagination-use-cases.
- Avoids a lot of bike-shedding.
PUT
vsPATCH
?400
vs422
? How to handle deprecation? These questions, and more, are no longer concerns. - The server's response can be extended to include more standardized behavior.
On the client-side (assuming that you're using a typed language):
- Your API is integrated with the editor - it'll suggest names, arguments, and return values - writing correct queries is much simpler.
- Your compiler will prevent the application from generating code with invalid queries.
About extensibility
Your server always supplies a JSON response. This means that you can add more fields to it if you'd like.
In PupilFirst, we've expanded the response object to include a notifications
field. If present, the response handler in the client automatically converts them into flash notifications that are shown to the user. This helps us preserve a Rails-like experience in our mutators, and keeps notifications DRY:
The query superclass has simple methods that inject notifications into the context...
...which then gets placed in the response by the GraphQL controller:
However, concerns still exist
This isn't what GraphQL promised
One of the stated advantages of GraphQL is that it solves the problem of over-fetching by allowing the client to specify exactly what data it needs, leading to the server fetching only the asked-for data.
This approach definitely ignores that goal. We're taking this approach because of two reasons:
- Over-fetching is not a problem for us. It might become a problem at scale, but we're not at that size yet. It's generally better to tackle problems that exist now (ease of API usage, and avoiding clerical mistakes), instead of one that might happen in the future.
- GraphQL doesn't actually do anything to solve over-fetching - it just specifies how to deliver the data once you've retrieved it. However, retrieving data correctly is still up to your business logic, which is always vaguely defined in all documentation that I've come across.
Arbitrarily loading relational data and incurring huge performance hits is one of the easiest mistakes to make with GraphQL, and it's not a problem whose solution is clear. At this point, I think it's appropriate to mention that Shopify has released a graphql-batch
gem that claims to tackle this issue. Unfortunately, I think it's poorly documented, and I couldn't really make sense of how it's supposed to work, but it may be worth looking at if you're already at scale, and dealing with issues like over-fetching.
Why not authorize fields?
The simple answer is that it's much easier to think about authorizing requests rather than fields. Requests always have a context which can be used to determine whether this user is allowed to access some data or make a change.
However, if the fields that a client can request are unbounded, i.e., the type allows the client to dig deeper into relationships and ask for distant data, then field-level authorization is your only option. This is why we suggest creating response types specific to queries if the requested data is complicated. Yes, this is restrictive, but requires only one authorization, and ensures that we're limiting the response to a selection of data that we know the client is definitely allowed to access.
How is this any different from REST?
First, I'd like to point you to the list of advantages written above.
You'll notice that the process I've suggested is very similar to how REST works. And you know what? REST has some really good ideas about how to manage communication - it's just that some of its requirements don't make sense anymore when building APIs. REST has an uncomplicated approach to authorization and data-delivery that I think we should adopt even when we're using GraphQL.
A real-world example
If you'd like to take a look at a Rails application that uses this approach, take a look at codebase for the PupilFirst LMS. The patterns described here were created as our team gradually switched to using ReasonML and ReasonReact on the front-end, and adopted GraphQL in order to leverage the presence of types and a compiler.
Top comments (0)