Our First Module

In Chapter 5, Making a Change with Mutations, we added several resolution functions that all copied an error-handling function we first developed to power the :create_menu_item mutation field. This error-handling function enabled the system to give users feedback about errors that bubble up from the underlying database—from internal schema-validation problems like missing and badly formatted arguments to database constraint violations.

Let’s take a look at that resolver again:

 def​ create_item(_, %{​input:​ params}, _) ​do
 case​ Menu.create_item(params) ​do
  {​:error​, changeset} ->
  {​:ok​, %{​errors:​ transform_errors(changeset)}}
  {​:ok​, menu_item} ->
  {​:ok​, %{​menu_item:​ menu_item}}
 end
 end

The output of the resolver is a :menu_item_result type, which we’ve defined as part of our schema, and includes an :errors field:

 object ​:menu_item_result​ ​do
  field ​:menu_item​, ​:menu_item
  field ​:errors​, list_of(​:input_error​)
 end

Our resolver builds the error portion of the result using a transform_errors/1 function that turns %Ecto.Changeset{} structs into :input_error objects:

 defp​ transform_errors(changeset) ​do
  changeset
  |> Ecto.Changeset.traverse_errors(&format_error/1)
  |> Enum.map(​fn
  {key, value} ->
  %{​key:​ key, ​message:​ value}
 end​)
 end
 
 @spec format_error(Ecto.Changeset.error) :: String.t
 defp​ format_error({msg, opts}) ​do
  Enum.reduce(opts, msg, ​fn​ {key, value}, acc ->
  String.replace(acc, ​"​​%{​​#{​key​}​​}"​, to_string(value))
 end​)
 end

This worked great when it was just the :create_menu_item mutation, but when we added more resolvers to power the ordering system, we just copied and pasted the same code into that resolver too. Clearly this isn’t the approach we would want to take as the API expands. One option for cleaning this up is to just extract the transform_errors/1 function into its own module, which we could import into both resolvers.

If we take a step back, however, we can look at this problem a different way. We got to this point because functions within our Menu and Ordering contexts end up returning {:error, %Ecto.Changeset{}} when validations fail, and Absinthe doesn’t really know what to do with a changeset. We might wonder, though, how Absinthe knows what to do with the existing {:ok, value} or {:error, error} tuples we’ve been using. If we knew what handled those tuples, we might be able to find a way to have Absinthe handle changesets, too.

Our first clue is to look closely at how the resolve macro is implemented:

 defmacro​ resolve(function_ast) ​do
 quote​ ​do
  middleware Absinthe.Resolution, ​unquote​(function_ast)
 end
 end

When you do something like this:

 resolve &Resolvers.Menu.menu_items/3

It expands to this in your schema:

 middleware Absinthe.Resolution, &Resolvers.Menu.menu_items/3

This Absinthe.Resolution middleware has been the driving force behind how our resolvers have operated this whole time, building the arguments to our resolvers, calling them, and then interpreting the result we’ve returned. However, we know more about our application than Absinthe does, so by building our own middleware, we can inform Absinthe about how to handle data that is more suited to our specific needs.

It’s time to upgrade our app by building our first middleware module.

If we want to let Absinthe know how to handle changeset errors, we’re going to need to build a middleware module to do so. Let’s start by ripping the error transformation logic out of our resolver modules and putting it inside a new module that will serve as the base of our middleware.

 defmodule​ PlateSlateWeb.Schema.Middleware.ChangesetErrors ​do
  @behaviour Absinthe.Middleware
 
 def​ call(res, _) ​do
 # to be completed
 end
 
 defp​ transform_errors(changeset) ​do
  changeset
  |> Ecto.Changeset.traverse_errors(&format_error/1)
  |> Enum.map(​fn
  {key, value} ->
  %{​key:​ key, ​message:​ value}
 end​)
 end
 
 defp​ format_error({msg, opts}) ​do
  Enum.reduce(opts, msg, ​fn​ {key, value}, acc ->
  String.replace(acc, ​"​​%{​​#{​key​}​​}"​, to_string(value))
 end​)
 end
 end

So far, it’s almost exactly what we had before, although we’re also indicating that this module implements the Absinthe.Middleware behaviour. Modules that implement this behaviour are required to define a call/2 function that takes an %Absinthe.Resolution{} struct as well as some optional configuration. The resolution struct is packed with information about the field that’s being resolved, including the results or errors that have been returned. We’ll get to the middleware configuration in a bit; for the moment, we can ignore it.

Similarities to Plug

images/aside-icons/info.png

The %Absinthe.Resolution{} struct plays a role similar to the %Plug.Conn{} struct. Each gets passed through a sequence of functions that can transform it for some purpose and return it at the end.

The overall approach we’re going to take with this call function is to look inside the resolution struct to see if we have a changeset error and, if we do, turn it into the structured error data we’ve been using.

 def​ call(res, _) ​do
 # to be completed
 with​ %{​errors:​ [%Ecto.Changeset{} = changeset]} <- res ​do
  %{res |
 value:​ %{​errors:​ transform_errors(changeset)},
 errors:​ [],
  }
 end
 end

Here we’re using the with pattern to check for this exact scenario. If this pattern doesn’t match, the call/2 will just return the resolution struct unchanged.

This code block introduces us to two of the most significant keys inside resolution structs: :value and :errors. The :value key holds the value that will ultimately get returned for the field, and is used as the parent for any subsequent child fields. The :errors key is ultimately combined with errors from every other field and is used to populate the top-level errors in a GraphQL result.

When you return {:ok, value} from within a resolver function, the value is placed under the :value key of the %Absinthe.Resolution{} struct. If you return {:error, error}, the error value is added to a list under the :errors key of the resolution struct.

Putting this knowledge together then, we can grasp the full picture of what our call/2 function is doing. We use the with macro to check for any changeset errors that would have been put there by a resolver returning {:error, changeset}. If we find one, we set the :value key to a map holding the transformed errors. We also clear out the :errors key, because we don’t want any of this to bubble up to the top level.

Now that we have this middleware, we’re going to look at how to place this on the schema so that it’s used during resolution.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.138.172.130