Error Better: Effectively Utilize Active Model Errors with Rails in API Mode

The Right Tool for the Job

It happens to all of us sometimes. We find a really useful tool and, once found, we keep reaching for it over and over again even when it might not be the best fit for the problem we want to solve. Sometimes it's worth going back to the docs to see if there is a better solution when your environment changes.

My most recent experience with this was Rails full_messages method. Since I started learning Rails using Michael Hartl's book, I have been building controllers with branching logic to handle validation errors and wondering at the magic of it all. When you combine full_messages with the traditionally rendered pages and flash it makes displaying an unknown number of errors to the user really convenient.

The difficulty came when I started stripping out ERB in favor of Vue and running Rails in API mode. Now, delivering a bunch of strings in an array that gets dumped into a div seems like a gross underutilization of what's possible. Even something as simple as placing the error under the field that has failed validation requires a bunch of extra work (e.g. RegEx), but why work harder than you have to.

Here is how to leverage Active Model errors to deliver usefully formatted JSON objects to your front-end framework.

Active Model Errors

First let's take a look at what error methods are available. The Active Model Errors pages is a great resource for seeing all the things you can do with error messages, but let's focus on the subset of error messages that return messages as key/value pairs and how they are formatted.

What are ActiveModel Errors anyway?

If an ActiveModel validation fails, it delivers an ActiveModel::Errors object, the most relevant portion being the @messages variable. Here is a simple example for a made up model called Item:

#<ActiveModel::Errors:0x00007fe63fcff710 
  @base=#<Item id: nil, name: nil, user_id: 15, created_at: nil, updated_at: nil>, 
  @messages={:name=>["can't be blank"]},  
  @details={:name=>[{:error=>:blank}]}>

You will also notice that there is an @details hash that will also give the type of error. This could be useful if you don't like the ActiveModel errors text and want to display your own text on the front end after matching on the error type (which is way more convenient than writing your own error messages in the controller).

Sending errors

To send the errors as key/value pairs, you can call .to_hash or .as_json which are defined in ActiveModel::Errors. Though it's not documented, you can also use to_json which will result in the same output, but doesn't allow you to use full_messages as shown below.

Note: You might be tempted to call the ruby method to_h on item like @item.errors.messages.to_h which will return the key/value pairs with a string as the value, but doing this will ignore any validation messages after the first one which is fine if you only have one validation, but makes your controller brittle to changes in the future.

Adding detail

The nice thing about both the to_hash and as_json methods is that they will take an optional argument to set the value of full_messages. Like the full_messages method mentioned at the top, this will include both the name of the object as well as the validation failure in human readable format. The useful thing here is that it is still tied to the object name as the key so your front-end doesn't need to do anything other than display the message(s) after matching on the key.

To include full_messages call errors like this:

@item.errors.to_hash(true)
  or
@item.errors.as_json(full_messages: true)

which will deliver a hash shaped like this:

{
  "name": [
      "Name is too long (maximum is 2 characters)",
      "Name has already been taken"
  ]
}

A little bit of cleanup

Personally, I like to wrap my error messages in an error key this way the front end gets not only an HTTP error code, but also an explicit error hash.

if @item.save
  ... do stuff ...
  }
else
  render status: 422, json: {
    errors: @dealsheet_view.errors.to_hash
  }
end

This is one way to return formatted errors as JSON, but of course, that's not the end of things. Once you see that ActiveModel::Errors is returning two hashes with differing levels of detail, there is really no limit to how you can manipulate this output to get exactly what you want for your use case.