Sapan Diwakar
Posted on July 11, 2023
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. But GraphQL convention clearly separates them into different top-level mutation
types.
Mutations in GraphQL
First, let’s see what a typical mutation looks like:
mutation CreatePost($post: PostCreateInput!) {
createPost(post: $post) {
post {
id
}
errors
}
}
This is a mutation to create a new post. It accepts a variable named post
of type PostCreateInput
(which is required because !
follows the type name). This variable is passed into the createPost
field inside the top-level mutation
type.
The result of that mutation is a post
(which can have further selections) and an errors
array. Note that we have named the mutation CreatePost
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 an input_object
that can be used as a variable for the mutation. Let’s create one now:
defmodule MyAppWeb.Schema do
use Absinthe.Schema
enum :post_state_enum do
value :active
value :draft
end
input_object :post_create_input do
field :title, non_null(:string)
field :author_id, non_null(:id)
field :body, non_null(:string)
field :state, :post_state_enum, default_value: :draft
end
end
Here, we use the enum
macro to create an enum named post_state_enum
with two possible values — draft
and active
.
We then use the input_object
macro to define an input object named post_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 a default_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 (like createPost
) will be fields inside this mutation:
defmodule MyAppWeb.Schema do
use Absinthe.Schema
mutation do
field :create_post, non_null(:post_mutation_result) do
arg :post, non_null(:post_create_input)
end
end
end
Here, we use the mutation..do..end
block to define the base mutation type. And inside it, we define a field named create_post
. This takes a single argument named post
of the post_create_input
type defined above. The result of this field is of type post_mutation_result
. We haven’t defined it yet, so let’s do that now.
object :post_mutation_result do
field :post, :post
field :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 from our previous post, a 3-arity resolver receives the parent object, the attributes, and an Absinthe.Resolution
struct. Let’s define that first to create the post:
defmodule MyAppWeb.Resolvers.PostResolver do
def create_post(_parent, %{post: attrs}, _resolution) do
case MyAppWeb.Blog.create_post(attrs) do
{:ok, post} ->
{:ok, %{post: post}}
{:error, changeset} ->
{:ok, %{errors: translate_changeset_errors(changeset)}}
end
end
end
In the resolver, we use the post
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 the errors
. Note that translate_changeset_errors
is a custom helper function that translates Ecto.Changeset
errors into an array of strings.
With the resolver in place, we can now plug it into our schema to create a post:
defmodule MyAppWeb.Schema do
use Absinthe.Schema
mutation do
field :create_post, :post_mutation_result do
arg :post, non_null(:post_create_input)
resolve &MyAppWeb.Resolvers.PostResolver.create_post/3
end
end
end
Now, when we execute the above mutation against our schema, Absinthe will call the create_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 the first post of this series, we used Absinthe.Plug
to handle all the requests coming into the /api
endpoint using the MyAppWeb.Schema
schema. This doesn’t handle any user authorization for us.
Absinthe provides a context concept containing shared information that might be useful for all queries and mutations. This is passed to all resolver functions that accept an Absinthe.Resolution
struct inside the context
field.
Let’s fix that first to identify users before 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:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :api do
plug :accepts, ["json"]
end
pipeline :graphql do
plug MyAppWeb.Schema.Context
end
scope "/" do
pipe_through [:api, :graphql]
forward "/api", Absinthe.Plug, schema: MyAppWeb.Schema
end
end
We've added a new MyAppWeb.Schema.Context
plug to the requests. Let’s implement it to put a user in the Absinthe context.
defmodule MyAppWeb.Schema.Context do
@behaviour Plug
import Plug.Conn
#...
def call(conn, _default) do
Absinthe.Plug.put_options(conn, context: absinthe_context(conn))
end
def absinthe_context(conn) do
%{conn: fetch_query_params(conn)}
|> put_user()
end
end
Note: I have intentionally left out some app-specific parts of the plug. Here is the full code of that module if you are interested.
An interesting part of the code in the above code block is the use of Absinthe.Plug.put_options/2
to set values that Absinthe.Plug
will pass to Absinthe when executing the query/mutation. This is similar to how we use Plug.Conn.assign
to assign something on the conn.
Internally, Absinthe.Plug
puts a private assign inside the conn
and passes it as the third parameter to Absinthe.run/3
when executing the document. The context
option we use above is a special value that Absinthe will then pass to all resolvers inside the Absinthe.Resolution
struct.
Now let’s update our create_post/3
resolver function to use this context and deny the request if the user isn’t present.
defmodule MyAppWeb.Resolvers.PostResolver do
def create_post(
_parent,
%{post: attrs},
%Absinthe.Resolution{context: %{user: nil}}
) do
{:error, "unauthorized"}
end
def create_post(_parent, %{post: attrs}, %Absinthe.Resolution{}) do
case MyAppWeb.Blog.create_post(attrs) do
{:ok, post} ->
{:ok, %{post: post}}
{:error, changeset} ->
{:ok, %{errors: translate_changeset_errors(changeset)}}
end
end
end
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 an author_id
in the post_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 write middleware MyAppWeb.Schema.RequireUser
in the field definition?
We can do that using the middleware/2
macro:
defmodule MyAppWeb.Schema do
use Absinthe.Schema
mutation do
field :create_post, :post_mutation_result do
arg :post, non_null(:post_create_input)
middleware MyAppWeb.Schema.RequireUser
resolve &MyAppWeb.Resolvers.PostResolver.create_post/3
end
end
end
The argument to middleware
is a module that implements the Absinthe.Middleware
behaviour. The module should start a call/2
function that receives an Absinthe.Resolution
struct as the first argument.
The second argument is whatever is passed to the middleware/2
macro. We don’t pass anything above, so it's nil by default. The call/2
function should return an updated Absinthe.Resolution
struct.
Implement RequireUser
Middleware
Let’s implement the RequireUser
middleware now to stop requests if a user is not present:
defmodule SpendraWeb.Schema.RequireUser do
@behaviour Absinthe.Middleware
def call(%Absinthe.Resolution{state: :resolved} = resolution, _config), do: resolution
def call(%Absinthe.Resolution{context: {user: nil}} = resolution, _config) do
Absinthe.Resolution.put_result(
resolution,
{:error, "You must be logged in to access this API"}
)
end
def call(%Absinthe.Resolution{} = resolution, _config), do: resolution
end
The middle clause is the most important. Here, we receive a context with a nil
value for the user. We use Absinthe.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 already resolved
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_result do
middleware MyAppWeb.Schema.RequireUser
middleware MyAppWeb.Schema.RequireAuthor
resolve &MyAppWeb.Resolvers.PostResolver.create_post/3
end
In fact, resolve
itself is just a middleware which roughly translates to middleware 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, first RequireUser
will be executed, followed by RequireAuthor
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!
Posted on July 11, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 12, 2020