A Deep Dive into Mutations with Absinthe

diwakarsapan

Sapan Diwakar

Posted on July 11, 2023

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. 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Marks the state of the resolution as resolved.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
diwakarsapan
Sapan Diwakar

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