Are you sick and tired of handling endless exceptions, writing custom logic to handle bad API requests and serializing the same errors over and over?
What if I told you there was a way to abstract away messy and repetitive error raising and response rendering in your Rails API? A way for you to write just one line of code (okay, two lines of code) to catch and serialize any error your API needs to handle? All for the low low price of just $19.99!
Okay, I'm kidding about that last part. This is not an infomercial.
Although Liz Lemon makes the snuggie (sorry, "slanket") look so good, just saying.
In this post, we'll come to recognize the repetitive nature of API error response rendering and implement an abstract pattern to DRY up our code. How? We'll define a set of custom errors, all subclassed under the same parent and tell the code that handles fetching data for our various endpoints to raise these errors. Then, with just a few simple lines in a parent controller, we'll rescue any instance of this family of errors, rendering a serialized version of the raised exception, thus taking any error handling logic out of our individual endpoints.
I'm so excited. Let's get started!
Recognizing the Repetition in API Error Response Rendering
The API
For this post, we'll imagine that we're working on a Rails API that serves data to a client e-commerce application. Authenticated users can make requests to view their past purchases and to make a purchase, among other things.
We'll say that we have the following endpoints:
POST '/purchases'
GET '/purchases'
Any robust API will of course come with specs.
API Specs
Our specs look something like this:
Purchases
Request
GET api/v1/purchases
# params
{
start_date: " ",
end_date: " "
}
Success Response
# body
{
status: "success",
data: {
items: [
{
id: 1,
name: "rice cooker",
description: "really great for cooking rice",
price: 14.95,
sale_date: "2016-12-31"
},
...
]
}
}
# headers
{"Authorization" => "Bearer <token>"}
Error Response
{
status: "error",
message: " ",
code: " "
}
code | message |
3000 | Can't find purchases without start and end date |
Yes, I've decided querying purchases requires a date range. I'm feeling picky.
Request
POST api/v1/purchases
# params
{
item_id: 2
}
Success Response
# body
{
status: "success",
data: {
purchase_id: 42,
item_id: 2
purchase_status: "complete"
}
}
# headers
{"Authorization" => "Bearer <token>"}
Error Response
{
status: "error",
message: " ",
code: " "
}
code | message |
4000 | item_id is required to make a purchase |
Error Code Pattern
With just a few endpoint specs, we can see that there is a lot of shared behavior. For the GET /purchases
request and POST /purchases
requests, we have two specific error scenarios. BUT, in both of the cases in which we need to respond with an error, the response format is exactly the same. It is only the content of the code
and message
keys of our response body that needs to change.
Let's take a look at what this error handling could look like in our API controllers.
API Controllers
# app/controllers/api/v1/purchases_controller.rb module Api module V1 class PurchasesController < ApplicationController def index if params[:start_date] && params[:end_date] render json: current_user.purchases else render json: {status: "error", code: 3000, message: "Can't find purchases without start and end date"} end end def create if params[:item_id] purchase = Purchase.create(item_id: params[:item_id], user_id: current_user.id) render json: purchase else render json: {status: "error", code: 4000, message: "item_id is required to make a purchase} end end end end end
Both of our example endpoints contain error rendering logic and they are responsible for composing the error to be rendered.
This is repetitious, and will only become more so as we build additional API endpoints. Further, we're failing to manage our error generation in a centralized away. Instead creating individual error JSON packages whenever we need them.
Let's clean this up. We'll start by building a set of custom error classes, all of which will inherit from the same parent.
Custom Error Classes
All of our custom error classes will be subclassed under ApiExceptions::BaseException
. This base class will contain our centralized error code map. We'll put our custom error classes in the lib/
folder.
# lib/api_exceptions/base_exception.rb module ApiExceptions class BaseException < StandardError include ActiveModel::Serialization attr_reader :status, :code, :message ERROR_DESCRIPTION = Proc.new {|code, message| {status: "error | failure", code: code, message: message}} ERROR_CODE_MAP = { "PurchaseError::MissingDatesError" => ERROR_DESCRIPTION.call(3000, "Can't find purchases without start and end date"), "PurchaseError::ItemNotFound" => ERROR_DESCRIPTION.call(4000, "item_id is required to make a purchase") } def initialize error_type = self.class.name.scan(/ApiExceptions::(.*)/).flatten.first ApiExceptions::BaseException::ERROR_CODE_MAP .fetch(error_type, {}).each do |attr, value| instance_variable_set("@#{attr}".to_sym, value) end end end end
We've done a few things here.
- Inherit
BaseException
fromStandardError
, so that instances of our class can be raised and rescued. - Define an error map that will call on a proc to generate the correct error code and message.
- Created
attr_reader
s for the attributes we want to serialize - Included
ActiveModel::Serialization
so that instances of our class can be serialized by Active Model Serializer. - Defined an
#initialize
method that will be called by all of our custom error child classes. When this method runs, each child class will use the error map to set the correct values for the@status
,@code
and@message
variables.
Now we'll go ahead and define our custom error classes, as mapped out in our error map.
# lib/api_exceptions/purchase_error.rb
module ApiExceptions
class PurchaseError < ApiExceptions::BaseException
end
end
# lib/api_exceptions/purchase_error/missing_dates_error.rb
module ApiExceptions
class PurchaseError < ApiExceptions::BaseException
class MissingDatesError < ApiExceptions::PurchaseError
end
end
end
# lib/api_exceptions/purchase_error/item_not_found.rb
module ApiExceptions
class PurchaseError < ApiExceptions::BaseException
class ItemNotFound < ApiExceptions::PurchaseError
end
end
end
Now that are custom error classes are defined, we're ready to refactor our controller.
Refactoring The Controller
For this refactor, we'll just focus on applying our new pattern to a single endpoint, since the same pattern can be applied again and again. We'll take a look at the POST /purchases
request, handled by PurchasesController#create
Instead of handling our login directly in the controller action, we'll build a service to validate the presence of item_id
. The service should raise our new custom ApiExceptions::PurchaseError::ItemNotFound
if there is no item_id
in the params.
module Api module V1 class PurchasesController < ApplicationController ... def create purchase_generator = PurchaseGenerator.new(user_id: current_user.id, item_id: params[:item_id]) render json: purchase_generator end end end end
Our service is kind of like a service-model hybrid. It exists to do a job for us––generate a purchase––but it also needs a validation and it will be serialized as the response body to our API request. For this reason, we'll define it in app/models
# app/models class PurchaseGenerator include ActiveModel::Serialization validates_with PurchaseGeneratorValidator attr_reader :purchase, :user_id, :item_id def initialize(user_id:, item_id:) @user_id = user_id @item_id = item_id @purchase = Purchase.create(user_id: user_id, item_id: item_id) if valid? end end
Now, let's build our custom validator to check for the presence of item_id
and raise our error if it is not there.
class PostHandlerValidator < ActiveModel::Validator def validate(record) validate_item_id end def validate_item_id raise ApiExceptions::PurchaseError::ItemNotFound.new unless record.item_id end end
This custom validator will be called with the #valid?
method runs.
So, the very simple code in our Purchases Controller will raise the appropriate error if necessary, without us having to write any control flow in the controller itself.
But, you may be wondering, how will we rescue or handle this error and render the serialized error?
Universal Error Rescuing and Response Rendering
This part is really cool. With the following line in our Application Controller, we can rescue *any error subclassed under ApiExceptions::BaseException
:
class ApplicationController < ActionController::Base rescue_from ApiExceptions::BaseException, :with => :render_error_response end
This line will rescue any such errors by calling on a method render_error_response
, which we'll define here in moment, and passing that method the error that was raised.
all our render_error_response
method has to do and render that error as JSON.
class ApplicationController < ActionController::Base rescue_from ApiExceptions::BaseException, :with => :render_error_response ... def render_error_response(error) render json: error, serializer: ApiExceptionsSerializer, status: 200 end end
Our ApiExceptionSerializer
is super simple:
class ApiExceptionSerializer < ActiveModel::Serializer
attributes :status, :code, :message
end
And that's it! We've gained super-clean controller actions that don't implement any control flow and a centralized error creation and serialization system.
Let's recap before you go.
Conclusion
We recognized that, in an API, we want to follow a strong set of conventions when it comes to rendering error responses. This can lead to repetitive controller code and an endlessly growing and scattered list of error message definitions.
To eliminate these very upsetting issues, we did the following:
- Built a family of custom error classes, all of which inherit from the same parent and are namespaced under
ApiExceptions
. - Moved our error-checking control flow logic out of the controller actions, and into a custom model.
- Validated that model with a custom validator that raises the appropriate custom error instance when necessary.
- Taught our Application Controller to rescue any exceptions that inherit from
ApiExceptions::BaseException
by rendering as JSON the raised error, with the help of our customApiExceptionSerializer
.
Keep in mind that the particular approach of designing a custom model with a custom validator to raise our custom error is flexible. The universally applicable part of this pattern is that we can build services to raise necessary errors and call on these services in our controller actions, thus keeping error handling and raising log out of individual controller actions entirely.