Handling Mutation Errors

In a perfect world, users would provide the correct data, conditions would always be met, mutations would execute cleanly, and changes would be persisted flawlessly. This isn’t a perfect world, however, so we need to deal with errors that might occur when executing our mutations. We’ll cover two major strategies that you can use to report errors to users. While you can’t always make them happy, you can at least let them know what went wrong.

Before we dig into error handling, we need to set up a good example. Let’s add a constraint in our PlateSlate application business logic so that we can easily trigger an error when creating menu items.

Right now in our PlateSlate application, we’re very permissive about menu item creation; besides checking that names and prices are provided, we allow any menu item to be created. We even allow menu items with duplicate names; let’s add a basic constraint in our database to prevent that.

Since we’re using Ecto for our PlateSlate project, we’ll use mix to generate a migration:

 $ ​​mix​​ ​​ecto.gen.migration​​ ​​AddIndexForMenuItemNames
 * priv/repo/migrations
 * priv/repo/migrations/20170826015057_add_index_for_menu_item_names.exs

Opening the migration file, we’ll add an index with a unique constraint:

 defmodule​ PlateSlate.Repo.Migrations.AddIndexForMenuItemNames ​do
 use​ Ecto.Migration
 
 def​ change ​do
  create unique_index(​:items​, [​:name​])
 end
 end

Now, run the migration:

 $ ​​mix​​ ​​ecto.migrate
 Running migration
 [info] create index menu_items_name_index
 [info] == Migrated in 0.0s

Now we have the database configured. We’ll add the unique constraint to the Ecto changeset for Menu.Item as well:

 def​ changeset(%Item{} = item, attrs) ​do
  item
  |> cast(attrs, [​:name​, ​:description​, ​:price​, ​:added_on​])
  |> validate_required([​:name​, ​:price​])
  |> foreign_key_constraint(​:category​)
» |> unique_constraint(​:name​)
 end

Let’s add a test that verifies our expectations for error handling as it stands right now:

 test ​"​​creating a menu item with an existing name fails"​,
 %{​category_id:​ category_id} ​do
  menu_item = %{
 "​​name"​ => ​"​​Reuben"​,
 "​​description"​ => ​"​​Roast beef, caramelized onions, horseradish, ..."​,
 "​​price"​ => ​"​​5.75"​,
 "​​categoryId"​ => category_id,
  }
  conn = build_conn()
  conn = post conn, ​"​​/api"​,
 query:​ @query,
 variables:​ %{​"​​menuItem"​ => menu_item}
  assert json_response(conn, 200) == %{
 "​​data"​ => %{​"​​createMenuItem"​ => nil},
 "​​errors"​ => [
  %{
 "​​locations"​ => [%{​"​​column"​ => 0, ​"​​line"​ => 2}],
 "​​message"​ => ​"​​Could not create menu item"​,
 "​​path"​ => [​"​​createMenuItem"​]
  }
  ]
  }
 end

Running this confirms that all is well:

 $ ​​mix​​ ​​test​​ ​​test/plate_slate_web/schema/mutation/create_menu_item_test.exs
 ..
 
 Finished in 0.2 seconds
 2 tests, 0 failures

Now you can have some peace of mind that when users create menu items, they will be prevented from using duplicate names. The only question is whether the errors you give your users are informative enough.

We’ll cover two approaches that you can use in your Absinthe schema to give users more information when they encounter an error: using simple :error tuples and modeling the errors directly as types.

Using Tuples

Field resolver functions return tuple values to indicate their result. We’ve already seen this in the resolvers we’ve built so far in our PlateSlate application. For example, here’s the resolver for the :create_menu_item field that we’ve been working on throughout this chapter:

 def​ create_item(_, %{​input:​ params}, _) ​do
 case​ Menu.create_item(params) ​do
  {​:error​, _} ->
  {​:error​, ​"​​Could not create menu item"​}
  {​:ok​, _} = success ->
  success
 end
 end

The return value for PlateSlate.Menu.create_item/1 is a tuple, and we use case to do a bit of post-processing on the value to return a nicely formatted value for Absinthe to include in the response. Successful values are returned untouched, but error values (which are Ecto changesets) are replaced with a pretty generic message, “Could not create menu item.” We can do better than that!

Here’s what a changeset looks like when returned from Ecto after an unsuccessful attempt to create a menu item:

 #Ecto.Changeset<action: :insert,
 changes:​ %{​name:​ ​"​​Water"​, ​price:​ ​#Decimal<0>},
 errors:​ [​name:​ {​"​​has already been taken"​, []}],
 data:​ ​#PlateSlate.Menu.Item<>, valid?: false>

Changesets are a pretty complex piece of machinery, and for good reason. We need to extract useful error information from this in a format that Absinthe can consume and return to users. Fortunately, Ecto.Changeset.traverse_errors/2 is a ready-made tool that’s perfect for our purposes. Let’s plug it into the resolver function, pulling the error information out of the changeset and returning it as part of the tuple:

 def​ create_item(_, %{​input:​ params}, _) ​do
 case​ Menu.create_item(params) ​do
  {​:error​, changeset} ->
  {
 :error​,
 message:​ ​"​​Could not create menu item"​,
»details:​ error_details(changeset),
  }
  success ->
  success
 end
 end
 
»def​ error_details(changeset) ​do
» changeset
» |> Ecto.Changeset.traverse_errors(​fn​ {msg, _} -> msg ​end​)
»end

The traverse_errors/2 function takes a changeset and a function to process each error, which is a two-element tuple of the error message. We’re transforming the error information into a string with format_error/1.

Errors Need a Message

images/aside-icons/tip.png

If you go beyond returning {:error, String.t} and return a map or keyword list, you must include a :message. Anything else is optional, but any error information must be serializable to JSON.

Instead of returning a simple {:error, String.t} value from the resolver, we’re now returning an {:error, Keyword.t}, with the error information from the changeset under the :details key. Here’s what the return value of the resolver will look like if a user encounters a name collision:

 {
 :error​,
 message:​ ​"​​Could not create menu item"​,
 details:​ %{
 "​​name"​ => [​"​​has already been taken"​]
  }
 }

It’s important to remember that errors are reported separate of data values in a GraphQL response, so the previous error would be serialized to look like this in a response:

 {
 "data"​: {
 "createMenuItem"​: ​null
  },
 "errors"​: [
  {
 "message"​: ​"Could not create menu item"​,
 "details"​: {
 "name"​: [​"has already been taken"​]
  },
 "locations"​: [{​"line"​: 2, ​"column"​: 0}],
 "path"​: [
 "createMenuItem"
  ]
  }
  ]
 }

Handily, the path to the related field is included, as well as line number information. This makes mapping an error to its originating point in our GraphQL schema (and document) pretty straightforward.

That Pesky Column Number

images/aside-icons/note.png

Notice that the column value in the error is 0. Due to a current limitation of the lexer that Absinthe uses (leex[21], part of Erlang/OTP), column tracking isn’t available...yet. For the moment, to be compatible with client tools, Absinthe always reports the column value as 0.

Let’s verify we get this error by modifying our test along the same lines:

 test ​"​​creating a menu item with an existing name fails"​,
 %{​category_id:​ category_id} ​do
  menu_item = %{
 "​​name"​ => ​"​​Reuben"​,
 "​​description"​ => ​"​​Roast beef, caramelized onions, horseradish, ..."​,
 "​​price"​ => ​"​​5.75"​,
 "​​categoryId"​ => category_id,
  }
  conn = build_conn()
  conn = post conn, ​"​​/api"​,
 query:​ @query,
 variables:​ %{​"​​menuItem"​ => menu_item}
 
  assert json_response(conn, 200) == %{
 "​​data"​ => %{​"​​createMenuItem"​ => nil},
 "​​errors"​ => [
  %{
 "​​locations"​ => [%{​"​​column"​ => 0, ​"​​line"​ => 2}],
 "​​message"​ => ​"​​Could not create menu item"​,
»"​​details"​ => %{​"​​name"​ => [​"​​has already been taken"​]},
 "​​path"​ => [​"​​createMenuItem"​]
  }
  ]
  }
 end

Running it, we see that it still passes:

 $ ​​mix​​ ​​test​​ ​​test/plate_slate_web/schema/mutation/create_menu_item_test.exs
 ..
 
 Finished in 0.2 seconds
 2 tests, 0 failures

With this in place, we know that the error information is being extracted from the changeset and being returned correctly from the resolver to our users. It will be handy to keep it around to fend off any regressions in the future.

Now let’s take a look at an alternate way to return errors.

Errors as Data

Sometimes, rather than returning errors in GraphQL’s free-form errors portion of the result, it might make sense to model our errors as normal data—fully defining the structure of our errors as normal types to support introspection and better integration with clients.

If you recall, the mutation field that we created earlier returned a :menu_item:

 mutation ​do
 
» field ​:create_menu_item​, ​:menu_item​ ​do
 # Contents
 end
 
 end

If we were to diagram the relationship between the resulting GraphQL types and fields, this is what it would look like:

images/chp.mutations/mutation-1.png

If we wanted to give our API clients more insight into the structure of the errors this mutation could return, we’d need to expand this modeling out a bit to make room for error types. What if, instead of returning the menu item directly, our mutation field returned an object type, :menu_item_result, that would sit in the middle?

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

This result models each part of the output, the menu item, and the errors. The :errors themselves are an object, which we’ll put in the schema because they’re generic enough to be used in a variety of places:

 @desc ​"​​An error encountered trying to persist input"
 object ​:input_error​ ​do
  field ​:key​, non_null(​:string​)
  field ​:message​, non_null(​:string​)
 end

The figure shows how the resulting GraphQL type structure would look, once we modify the mutation field to declare its result to be a :menu_ item_result.

images/chp.mutations/mutation-2.png

Let’s do that, modifying the field resolver:

 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
 
 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

It’s important to notice that, regardless of error state, an :ok tuple is returned; it’s just doing the work of translating database errors into values that can be transmitted back to clients.

GraphQL documents from the clients wouldn’t look much different; they’d just be a level deeper. Let’s modify the query we’re using in our tests to support this new structure:

 @query ​"""
 mutation ($menuItem: MenuItemInput!) {
  createMenuItem(input: $menuItem) {
  errors { key message }
  menuItem {
  name
  description
  price
  }
  }
 }
 """

When clients receive responses for this document, they can interpret the success of the result by checking the value of menuItem and/or errors, then give feedback to users appropriately. Because the errors are returned as the result of specific fields, this means that, even in cases where the client sends multiple mutations in a single document, any errors encountered can be tied to the specific mutation that failed.

For our tests to work with the changes to the field and this new query, we need to update the assertions we make about the responses we expect.

We’ll start with the case that successfully creates a menu item:

 assert json_response(conn, 200) == %{
 "​​data"​ => %{
 "​​createMenuItem"​ => %{
 "​​errors"​ => nil,
 "​​menuItem"​ => %{
 "​​name"​ => menu_item[​"​​name"​],
 "​​description"​ => menu_item[​"​​description"​],
 "​​price"​ => menu_item[​"​​price"​]
  }
  }
  }
 }

We also need to update the error case:

 assert json_response(conn, 200) == %{
 "​​data"​ => %{
 "​​createMenuItem"​ => %{
 "​​errors"​ => [
  %{​"​​key"​ => ​"​​name"​, ​"​​message"​ => ​"​​has already been taken"​}
  ],
 "​​menuItem"​ => nil
  }
  }
 }

With the test assertions updated, your test assertions should pass. Let’s run the tests for the field again:

 $ ​​mix​​ ​​test​​ ​​test/plate_slate_web/schema/mutation/create_menu_item_test.exs
 ..
 
 Finished in 0.2 seconds
 2 tests, 0 failures

This is a straightforward approach to modeling errors as data, and it’s possible to take it a lot further—for example, supporting a fully fleshed out error code system using enums or setting a union as the result type—but it’s important to make decisions about error modeling based on the needs of your API users. Remember, if users don’t need to know the structure of your errors ahead of time, or if you don’t think supporting introspection for documentation purposes is worth it, even this basic modeling is overkill; just return simple :error tuples instead. They’re low ceremony and flexible enough to support most use cases.

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

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