
A Deep Dive into Mutations with Absinthe
In the last two posts of this series, we saw how easy it is to integrate Absinthe into your app to provide a GraphQL API for querying data. We also shared some useful tips for building and maintaining large schemas and optimizing queries.
This post will show how we can provide an API to create GraphQL mutations with Absinthe for Elixir.
Let's get started!
A Note on Queries Vs. Mutations in GraphQL
GraphQL places a clear distinction between queries and mutations. Technically, we can implement any query to write data to the server. ButGraphQL convention clearly separates them into different top-levelmutation
types.
Mutations in GraphQL
First, let’s see what a typical mutation looks like:
mutationCreatePost($post:PostCreateInput!){createPost(post:$post){post{id}errors}}
This is a mutation to create a new post. It accepts a variable namedpost
of typePostCreateInput
(which is required because!
follows the type name). This variable is passed into thecreatePost
field inside the top-levelmutation
type.
The result of that mutation is apost
(which can have further selections) and anerrors
array. Note that we have named the mutationCreatePost
but that is just a client-side identifier. Here is what a sample response from this mutation looks like:
{"data":{"createPost":{"post":{"id":"1"},"errors":null}}}
Input Objects with Absinthe for Elixir
Let’s see how we can implement this with Absinthe. The first thing we need is aninput_object
that can be used as a variable for the mutation. Let’s create one now:
defmoduleMyAppWeb.SchemadouseAbsinthe.Schemaenum:post_state_enumdovalue:activevalue:draftendinput_object:post_create_inputdofield:title,non_null(:string)field:author_id,non_null(:id)field:body,non_null(:string)field:state,:post_state_enum,default_value::draftendend
Here, we use theenum
macro to create an enum namedpost_state_enum
with two possible values —draft
andactive
.
We then use theinput_object
macro to define an input object namedpost_create_input
which has four fields. Note that the definition of fields changes slightly from what we would use in an output type. For example, for fields inside an input object, we can add adefault_value
to a non-required field. Absinthe will fill the field in if the user doesn’t provide a value.
Defining Mutations in Absinthe
Next, let’s define our mutation type in the schema. All individual mutations (likecreatePost
) will be fields inside this mutation:
defmoduleMyAppWeb.SchemadouseAbsinthe.Schemamutationdofield:create_post,non_null(:post_mutation_result)doarg:post,non_null(:post_create_input)endendend
Here, we use themutation..do..end
block to define the base mutation type. And inside it, we define a field namedcreate_post
. This takes a single argument namedpost
of thepost_create_input
type defined above. The result of this field is of typepost_mutation_result
. We haven’t defined it yet, so let’s do that now.
object:post_mutation_resultdofield:post,:postfield:errors,list_of(non_null(:string))end
Now on to the interesting part — actually performing the mutation operation. This is similar to what we already did for queries. We will define a resolver to handle it.
If you remember fromour previous post, a 3-arity resolver receives the parent object, the attributes, and anAbsinthe.Resolution
struct. Let’s define that first to create the post:
defmoduleMyAppWeb.Resolvers.PostResolverdodefcreate_post(_parent,%{post:attrs},_resolution)docaseMyAppWeb.Blog.create_post(attrs)do{:ok,post}->{:ok,%{post:post}}{:error,changeset}->{:ok,%{errors:translate_changeset_errors(changeset)}}endendend
In the resolver, we use thepost
attributes to create a post. If that is successful, we respond with the post object (the result map should correspond to the mutation's result type —post_mutation_result
, in our case). On an error, we respond with theerrors
. Note thattranslate_changeset_errors
is a custom helper function that translatesEcto.Changeset
errors into an array of strings.
With the resolver in place, we can now plug it into our schema to create a post:
defmoduleMyAppWeb.SchemadouseAbsinthe.Schemamutationdofield:create_post,:post_mutation_resultdoarg:post,non_null(:post_create_input)resolve&MyAppWeb.Resolvers.PostResolver.create_post/3endendend
Now, when we execute the above mutation against our schema, Absinthe will call thecreate_post
function. The return value from the resolver's{:ok, value}
tuple will then be used as the mutation's result and returned to the user.
Authorization in Your Elixir App with GraphQL and Absinthe
Up until now, we have been discussing everything as if we're using an open public API. But in most apps, this is not how things work. Often, certain APIs are only available to logged-in users, and this is even more important in the case of mutations. Let’s see how we can add authorization to our API.
First, we will need to identify the users making requests. In thefirst post of this series, we usedAbsinthe.Plug
to handle all the requests coming into the/api
endpoint using theMyAppWeb.Schema
schema. This doesn’t handle any user authorization for us.
Absinthe provides acontext concept containing shared information that might be useful for all queries and mutations. This is passed to all resolver functions that accept anAbsinthe.Resolution
struct inside thecontext
field.
Let’s fix that first to identify usersbefore a request is forwarded to Absinthe.
An Example Using Absinthe Context
Update the router in your app to execute an additional plug before forwarding a request:
defmoduleMyAppWeb.RouterdouseMyAppWeb,:routerpipeline:apidoplug:accepts,["json"]endpipeline:graphqldoplugMyAppWeb.Schema.Contextendscope"/"dopipe_through[:api,:graphql]forward"/api",Absinthe.Plug,schema:MyAppWeb.Schemaendend
We've added a newMyAppWeb.Schema.Context
plug to the requests. Let’s implement it to put a user in the Absinthe context.
defmoduleMyAppWeb.Schema.Contextdo@behaviourPlugimportPlug.Conn#...defcall(conn,_default)doAbsinthe.Plug.put_options(conn,context:absinthe_context(conn))enddefabsinthe_context(conn)do%{conn:fetch_query_params(conn)}|>put_user()endend
Note: I have intentionally left out some app-specific parts of the plug. Here is thefull code of that module if you are interested.
An interesting part of the code in the above code block is the use ofAbsinthe.Plug.put_options/2
to set values thatAbsinthe.Plug
will pass to Absinthe when executing the query/mutation. This is similar to how we usePlug.Conn.assign
to assign something on the conn.
Internally,Absinthe.Plug
puts a private assign inside theconn
and passes it as the third parameter toAbsinthe.run/3
when executing the document. Thecontext
option we use above is a special value that Absinthe will then pass to all resolvers inside theAbsinthe.Resolution
struct.
Now let’s update ourcreate_post/3
resolver function to use this context and deny the request if the user isn’t present.
defmoduleMyAppWeb.Resolvers.PostResolverdodefcreate_post(_parent,%{post:attrs},%Absinthe.Resolution{context:%{user:nil}})do{:error,"unauthorized"}enddefcreate_post(_parent,%{post:attrs},%Absinthe.Resolution{})docaseMyAppWeb.Blog.create_post(attrs)do{:ok,post}->{:ok,%{post:post}}{:error,changeset}->{:ok,%{errors:translate_changeset_errors(changeset)}}endendend
If a resolver function returns an error tuple, Absinthe will not resolve that field and adds an error to the response.
This is just one example of how to use Absinthe context. There are many more advanced potential use cases. For example, you might want to automatically set a user’s id as the post author, instead of accepting anauthor_id
in thepost_create_input
.
Middleware in Absinthe for Elixir
We have used context to authorize a user during resolution. But if we have many fields to handle, this soon becomes too cumbersome. Absinthe provides middleware to handle such cases.
For example, let's assume that several fields require a user to be present when performing an operation. Instead of updating the resolver function for all those fields, wouldn’t it be better if we could just writemiddleware MyAppWeb.Schema.RequireUser
in the field definition?
We can do that using themiddleware/2
macro:
defmoduleMyAppWeb.SchemadouseAbsinthe.Schemamutationdofield:create_post,:post_mutation_resultdoarg:post,non_null(:post_create_input)middlewareMyAppWeb.Schema.RequireUserresolve&MyAppWeb.Resolvers.PostResolver.create_post/3endendend
The argument tomiddleware
is a module that implements theAbsinthe.Middleware
behaviour. The module should start acall/2
function that receives anAbsinthe.Resolution
struct as the first argument.
The second argument is whatever is passed to themiddleware/2
macro. We don’t pass anything above, so it's nil by default. Thecall/2
function should return an updatedAbsinthe.Resolution
struct.
ImplementRequireUser
Middleware
Let’s implement theRequireUser
middleware now to stop requests if a user is not present:
defmoduleSpendraWeb.Schema.RequireUserdo@behaviourAbsinthe.Middlewaredefcall(%Absinthe.Resolution{state::resolved}=resolution,_config),do:resolutiondefcall(%Absinthe.Resolution{context:{user:nil}}=resolution,_config)doAbsinthe.Resolution.put_result(resolution,{:error,"You must be logged in to access this API"})enddefcall(%Absinthe.Resolution{}=resolution,_config),do:resolutionend
The middle clause is the most important. Here, we receive a context with anil
value for the user. We useAbsinthe.Resolution.put_result
to mark that the resolution is now complete. It does two things:
- Marks the
state
of the resolution asresolved
. - Puts the specified value as the resolution result. Here we use an error tuple to signal that this is an erroneous response.
We also have a special clause at the beginning that checks for the resolution's existing state. If you are using multiple middlewares in your app, this is important. It avoids a middleware running on a resolution that has already been resolved.
Finally, if a resolution's state is not alreadyresolved
and we have a user in the context, we just return the original resolution struct without any modifications. This allows execution to proceed.
Chaining Middleware
The great thing about middleware is that it can be chained. For example, we might have several user roles and only want authors to be able to create posts. We can use multiple middleware clauses that each perform a single task before the final resolution.
field:create_post,:post_mutation_resultdomiddlewareMyAppWeb.Schema.RequireUsermiddlewareMyAppWeb.Schema.RequireAuthorresolve&MyAppWeb.Resolvers.PostResolver.create_post/3end
In fact,resolve
itself is just a middleware which roughly translates tomiddleware Absinthe.Resolution, unquote(function_ast)
.
Another important point about middlewares is that they are executed in the order they are defined in a field. So in the above example, firstRequireUser
will be executed, followed byRequireAuthor
and, finally, the resolver.
It is also possible to use middleware after a resolve call — for example, to handle resolution errors.
Wrap Up
In this post, we created GraphQL mutations with Absinthe. We also added a custom plug to put shared information in an Absinthe context and defined middleware to handle common tasks like authorization.
In the next and final part of this series, we will discuss advanced GraphQL use cases like subscriptions (including how to implement and deliver subscriptions over a WebSocket connection).
Happy coding!
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press,subscribe to our Elixir Alchemy newsletter and never miss a single post!
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse