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.