Middleware to process resolution errors from context

Hi,

I have an issue with a GraphQL api developed with Absinthe.

Today, for errors returned from my context functions that I consider GraphQL errors (not supposed to happen if the api is used correctly), here is what I do:

defmodule MyAppWeb.Resolver.Accounts.ChangeUserUsername do
  @moduledoc false

  alias MyAppWeb.Resolver.Accounts.Error
  alias MyAppWeb.Resolver.Success

  def resolve(_parent, %{input: input}, %{context: %{principal: principal}} = resolution) do
    raw_input = Map.get(resolution.definition.argument_data, :input)

    case MyApp.Accounts.change_user_username(input, principal: principal) do
      {:ok, _user} ->
        {:ok, %Success{}}

      {:error, :user_not_found} ->
        %{user_id: user_id} = raw_input

        {:ok,
         %Error.UserNotFound{
           message: "No user was found with ID \"#{user_id}\"."
         }}

      {:error, :username_already_taken} ->
        %{username: username} = raw_input

        {:ok,
         %Error.UsernameAlreadyTaken{
           message: "Username \"#{username}\" is already taken by another user."
         }}

      {:error, error} ->
        {:middleware, MyAppWeb.Middleware.HandleError, error}
    end
  end
end

Ultimately, I return {:middleware, MyAppWeb.Middleware.HandleError, error} to process errors that are not supposed to happen. All my resolvers work the same way.

Here is the middleware source:

defmodule MyAppWeb.Middleware.HandleError do
  @moduledoc false

  @behaviour Absinthe.Middleware

  require Logger

  alias MyAppWeb.Resolver.GraphQLErrors

  @impl true

  def call(resolution, error) do
    graphql_error = handle_error(error, resolution)

    Absinthe.Resolution.put_result(resolution, {:error, graphql_error})
  end

  defp handle_error({:validation_failure, changeset}, resolution) do
    GraphQLErrors.invalid_input_argument(changeset, fn field ->
      resolution.adapter.to_external_name(field, :field)
    end)
  end

  defp handle_error({:unauthorized, reason}, _resolution) do
    GraphQLErrors.access_forbidden(reason)
  end

  defp handle_error(error, _resolution) do
    Logger.error(fn ->
      "Unexpected error when resolving query: #{inspect(error)}"
    end)

    GraphQLErrors.unexpected_error()
  end
end
defmodule MyAppWeb.Resolver.GraphQLErrors do
  @moduledoc false

  alias MyAppWeb.Resolver.ErrorFormatter

  def unauthenticated do
    %{
      message: "Cannot process the query because no principal is authenticated.",
      extensions: %{
        code: "UNAUTHENTICATED"
      }
    }
  end

  def access_forbidden(reason) do
    %{
      message: "Access forbidden.",
      extensions: %{
        code: "FORBIDDEN",
        details: reason
      }
    }
  end

  def invalid_input_argument(changeset, convert_field_name) do
    %{
      message: "Argument \"input\" has invalid value.",
      extensions: %{
        code: "INVALID_ARGUMENT",
        details: ErrorFormatter.format(changeset, convert_field_name)
      }
    }
  end

  def unexpected_error do
    %{
      message: "An unexpected error happened.",
      extensions: %{
        code: "UNEXPECTED_ERROR"
      }
    }
  end
end

The job of the middleware is to turn cryptic error keywords and details into friendly GraphQL errors with a proper message and error code.

My issue is that I have to explicitly return the middleware from all my resolver functions. Is there a way (middleware, plugin or else) to add additional processing after the resolver function returns but before the call to put_result/2?
I cannot just place a middleware after the resolve function and process the errors from the resolution struct because there might be other errors unrelated to the result of the resolver function.

I would like to run something like:

def call(resolution, result) do
  with {:error, error} <- result do
    graphql_error = handle_error(error, resolution)
    {:error, graphql_error}
  end
end

That would simplify all my resolver functions. Is this possible? recommended?

@benwilson512: what would the best way to generically process the results return by the resolver functions before the absinthe calls put_result/2?

@itopiz Sure, have you seen Absinthe.Middleware — absinthe v1.7.0?

I did see it but the problem I have is that I cannot tell apart the errors resulting from the resolver function from all the errors in the Absinthe.Resolution struct.

So, if I code such a middleware:

defmodule MyFeedWeb.Middleware.ProcessResolutionError do
  @moduledoc false

  @behaviour Absinthe.Middleware

  require Logger

  alias MyFeedWeb.Resolver.GraphQLErrors

  @impl true
  def call(resolution, _) do
    errors = resolution.errors 
    |> Enum.map(&handle_error(&1, resolution))

    %{resolution | errors: errors}
  end

  defp handle_error({:validation_failure, changeset}, resolution) do
    GraphQLErrors.invalid_input_argument(changeset, fn field ->
      resolution.adapter.to_external_name(field, :field)
    end)
  end

  defp handle_error({:unauthorized, reason}, _resolution) do
    GraphQLErrors.access_forbidden(reason)
  end

  defp handle_error(error, _resolution) do
    Logger.error(fn ->
      "Unexpected error when resolving query: #{inspect(error)}"
    end)

    GraphQLErrors.unexpected_error()
  end
end

Then MyAppWeb.Middleware.ProcessResolutionError will consider errors set before the resolution as an unexpected error instead of leaving them as they are.

An example of an error set before the resolution is the error is set when no user/principal is authenticated:

defmodule MyFeedWeb.Middleware.EnsureAuthenticated do
  @moduledoc false

  @behaviour Absinthe.Middleware

  alias MyFeedWeb.Resolver.GraphQLErrors

  def call(resolution, _opts) do
    case resolution.context do
      %{principal: _principal} ->
        resolution

      _ ->
        resolution
        |> Absinthe.Resolution.put_result({:error, GraphQLErrors.unauthenticated()})
    end
  end
end

Sorry if my initial question was unclear, I hope it is better now.

@benwilson512 did my previous message clarify my issue?