TL;DR; Thinking GraphQL mutations are a bit too verbose to define? A bit of introspection can help define base mutations for common operations. Need to do something else? Writing your own custom mutations is simple, really.
In today's episode we are going to talk about mutations and how to define them The Rails Way™.
Mutations you say? Yes. If you're familiar with REST, you know how CRUD operations are codified. One uses POST for create actions, PUT or PATCH for update actions and DELETE for destroy actions.
This HTTP verb mapping always makes developers wonder whether they should use POST, PUT or PATCH when actions fall outside of traditional CRUD operations. For example, let's consider a REST action which is "approve a transaction". Which verb should you use? Well it depends.
If you consider that the transaction is getting updated, you should use PUT or PATCH. Now if you consider that you create an approval, which in turns updates the transaction then you should use POST.
I dislike these dilemmas because each developer will have a different view on it. And as more developers work on your API well...inconsistencies will spawn across your REST actions.
With GraphQL there are no such questions. You keep hitting the /graphql endpoint with POST requests!
A mutation is simple: it's an operation which leads to a write, somewhere. It can be anything, such as:
- Create, update or delete a record
- Approve a transaction
- Rollback a record
- Order a pizza
- Bulk update a series of records
- Post an image on Imgur
So let's see how to define mutations with graphql-ruby and - more importantly - how to make them reusable ;)
In this episode I will be reusing the Book(name, page_size, user_id) and User(name, email) models we defined in the previous episode
All mutations must be registered in the Types::MutationType file, the same way queryable resources must be declared in the Types::QueryType file.
Mutations can be declared in block form inside the Types::MutationType file but that's kind of messy. We'll use a proper mutation class to define our mutation.
First, let's register our mutation. The class doesn't exist yet but we'll get it soon.
Nothing complicate here. It's fairly straightforward.
Now to write a mutation, we need to define four aspects:
- The required (e.g. ID) and accepted arguments (e.g. update attributes)
- The return field, similar to a queryable resource
- The authorization logic - Is the user allowed to perform the action
- The actual mutation logic and return value
That sounds quite reasonable, you would expect this sequence from any controller action. So let's see what the mutation looks like.
That's all you need. Now let's try to update a book using GraphiQL. You'll note that, once again, our mutation is properly documented on the right side :)
Now let's try to update a book that doesn't exist:
Finally let's add some validation on the pages attribute of our ActiveRecord model:
Now let's try to update our book with a negative page size:
As expected, the update is rejected and we get a proper message describing the error.
All in all I find that defining mutations is quite an easy process, much more than defining queryable resources. This is mainly due to their atomic nature, where each mutation does only one thing. On the other side queryable resources have a greater complexity due to the many functionalities we expect from them (pagination, filtering, sorting, embedded resources etc.)
Now as you can guess, we can simplify the definition of mutations for CUD operations with some metaprogramming. The gain is not going to be as obvious as with queryable resources, but it's still there - especially because we'll standardize the way they are defined and returned.
As part of this simplification process, we'll also improve error formatting. Returning error messages is a bit rough. Having a message, a code and a path (= which input triggered the error) would be much nicer.
Let's get to work!
In order to standardize our mutations we'll need to define three things:
- A standard type for mutation errors
- A standard return type for our mutations
- Base classes for CUD operations (Create / Update / Delete)
The mutation error type
If you've read our previous episode you should be comfortable with types already.
The mutation error type is defined as follow:
We're done here. Let's use that new type in our standard return type for mutations.
Standard type for mutations
All mutations should inherit from Mutations::BaseMutation.
You can then define child base classes for each mutation flavor (e.g. create/update/delete). But all should be inheriting from Mutations::BaseMutation - this way all your mutations will be consistent.
So what do we want to enforce in all our mutations? The following concepts:
- A success field returning whether the operation was successful or not
- An errors array returning details about what failed during the operation
- Some default authorization logic
- A helper to format ActiveRecord errors into Types::MutationErrorType
With the above in mind, here is what our Mutations::BaseMutation class looks like:
Most of the complicated code is related to error formatting, which requires a bit of array hula hoop. That code aside, we're really just enforcing standard return fields (success and errors).
Let's tackle our CUD operations now.
Standard create mutation and how to use it
The create mutation is going to handle the following aspects:
- Authorization - is your user allowed to perform this object creation?
- Creation logic
- Expand the Mutations::BaseMutation return fields (success, errors) with the model field (e.g. book or user) to provide the created object.
What it is NOT going to handle is:
- Define which fields are required/accepted for the creation of the resource. This is something we will define in the resource-specific mutation sub-class.
The use of Pundit (or similar framework) is optional but strongly recommended. Look at the authorized? method and adapt based on your needs.
The base create mutation looks like this:
It looks like a lot of code but it doesn't do that much. Think about our previous updateBook mutation - the rest is just metaprogramming overhead.
Now let's implement the actual create mutation for our book model.
And register our create mutation:
You can now create books via GraphiQL:
If validation happens to fail, you'll get nicely structured errors:
The Mutations::BaseCreateMutation comes with a few additional goodies. Let's say you need to set the book ownership to the current user and perform some post-create actions.
You could rework your book mutation the following way. Of course it assumes that the GraphQL context has a current_user attribute defined. You should read our previous episode to know more about this.
The base update and delete mutations will be very similar. Let's go over them briefly.
Standard update mutation and how to use it
The base update mutation is a bit more complex than the base create mutation because:
- It needs to find the record. By default we assume it is by ID.
- To find the record, we leverage Pundit scopes (if defined) to check if the record is actually visible to the user.
- Once found, we assign the attributes to the record to check whether the user is allowed to perform the actual update
- We perform the update inside a transaction
The base update mutation looks like this:
And our book update mutation can now be reworked like this:
Do not forget to register your book update mutation:
We can now update our book via GraphiQL again. The only difference with our previous approach is that the errors field is now an array of objects.
Standard delete mutation and how to use it
The base delete mutation is very similar to the base update one. The only difference is:
- Only one argument is required on the child class (usually the id)
- The destroy method can be customized
The base delete mutation looks like this:
And the actual delete mutation looks like this:
The mutation type now has all the CUD operations defined:
Now let's destroy a book via GraphiQL:
🎉 Tada! 🎉
What if I want to do a custom mutation?
Remember that mutations can be any mutating action.
CUD operations will be covered by the standard mutations we have written but in case you need to do something different, just go back to Mutations::BaseMutation - don't try to bend our standard CUD mutations.
This base mutation simply defines the response style of your mutations. It is responsible for making your API consistent. In the end all you need to do is:
- Define arguments
- Define the main field (e.g. book), which is returned on top of success and errors.
- Define the authorized? method
- Define the resolve method
Let's create a "copy book" mutation for instance. This mutation doesn't really fit within the scope of our standard mutations so we'll write it from scratch.
Once again, do not forget to update your mutation type:
And now copy your book!
I have come to really like GraphQL mutations because I find them simple and consistent. One defines the input, output, authorization and business logic and you're done. There is no real magic - it's very readable.
The metaprogramming we presented above does not do much in the end, but it does save you a lot of boilerplate code when you need to expose a lot of resources - especially if you are working on an existing project.
The key takeaway here? Standard CUD mutations are nice but if you need to do anything specific, just go back to using Mutations::BaseMutation. Bending your standard mutations to fit exotic use cases will just make your code difficult to read.
Mutations are simple and atomic, keep them that way.
Keypup is on a mission to help developers and tech leads work better with each others on development projects. Our platform automatically centralizes, prioritizes and assigns people and actions on issues and pull requests to optimize your development flow.
Don't get lost because you have to juggle with twenty pull requests across five development projects. We'll clean and organize that for you to ensure a smooth landing.
Code snippets hosted with ❤ by GitHub