- Use exception handling techniques like
rescueandrescue_fromin a Rails controller
In this lesson, we'll finish work on our Bird API by refactoring the controller to add in some helpful reusable error handling code. To get set up, run:
$ bundle install
$ rails db:migrate db:seed
$ rails sThis will download all the dependencies for our app, set up the database, and run the Rails server.
<iframe width="560" height="315" src="https://www.youtube.com/embed/evlSdyGoE3s?rel=0&showinfo=0" frameborder="0" allowfullscreen></iframe>In the current implementation of our BirdsController, we've defined actions to
handle all five RESTful routes plus one additional custom route. You'll notice
there is some common behavior between a lot of the methods. For all the routes
that include a route parameter (/birds/:id), we're using the ID in the params
hash to look up a bird; if the bird is found, we're performing some action with
it, and if not, we're sending an error message back.
For example, have a look at the show and update actions:
# GET /birds/:id
def show
bird = Bird.find_by(id: params[:id])
if bird
render json: bird
else
render json: { error: "Bird not found" }, status: :not_found
end
end
# PATCH /birds/:id
def update
bird = Bird.find_by(id: params[:id])
if bird
bird.update(bird_params)
render json: bird
else
render json: { error: "Bird not found" }, status: :not_found
end
endBetween these two methods, there's a good amount of repeated code:
- Finding a bird based on the ID
- Performing control flow (if/else) based on whether or not the bird exists
- Returning an error message with a status of
:not_foundif the bird doesn't exist
That same code also exists in the increment_likes and destroy actions. That
makes this a good opportunity for a refactor to DRY up some of this repeated
logic!
Let's start by making a private method for generating the :not_found response:
private
def render_not_found_response
render json: { error: "Bird not found" }, status: :not_found
endWe can then update our actions to use this method instead of implementing the rendering logic directly:
# GET /birds/:id
def show
bird = Bird.find_by(id: params[:id])
if bird
render json: bird
else
render_not_found_response
end
end
# PATCH /birds/:id
def update
bird = Bird.find_by(id: params[:id])
if bird
bird.update(bird_params)
render json: bird
else
render_not_found_response
end
endWe can also make a helper method to find a bird based on the ID in the params hash:
private
def find_bird
Bird.find_by(id: params[:id])
endNow, our controller actions don't need to worry about how the find_bird method
is implemented, as long as it returns a bird from the database. This frees us up
to change how the bird finding logic is implemented in the future (for example,
using something other than the ID to look up a bird in the database, like a URL
slug or UUID).
Here's how our controller actions can use this method:
# GET /birds/:id
def show
bird = find_bird
if bird
render json: bird
else
render_not_found_response
end
end
# PATCH /birds/:id
def update
bird = find_bird
if bird
bird.update(bird_params)
render json: bird
else
render_not_found_response
end
endWe can also shorten up the code in each of our controller methods by using a
different approach to finding a bird using the ID. This will also help us
improve our error handling. Currently, we're using the find_by
method to look up a bird. find_by returns nil if the record isn't found in
the database, which makes it useful for if/else control flow, since nil is a
false-y value in Ruby.
If we use the find method instead, we'll get an
ActiveRecord::RecordNotFound exception instead of nil when the record
doesn't exist. Try updating the find_bird action like this:
def find_bird
Bird.find(params[:id])
endThen make a request for an ID that doesn't exist in the database, like
localhost:3000/birds/9999. You should see an error message like this:
ActiveRecord::RecordNotFound (Couldn't find Bird with 'id'=9999)We can handle this error in our controller method by using a
rescue block in our method, like so:
def show
bird = find_bird
render json: bird
rescue ActiveRecord::RecordNotFound
render_not_found_response
endNot only is this code shorter than the previous implementation, it also gives a
clearer separation between the "happy path" of our code (no exceptions/errors)
and the logic for handling exceptions/errors. Try making the same request in the
browser to localhost:3000/birds/9999 — now that we're handling the exception
in the controller, you should see a 404 status code in the console with the { "error": "Bird not found" } JSON response instead of a 500 server error.
We use the same approach to our update action as well:
def update
bird = find_bird
bird.update(bird_params)
render json: bird
rescue ActiveRecord::RecordNotFound
render_not_found_response
endThe tradeoff to this approach of using exception handling rather than an if/else
control flow is that it may be less apparent to other developers looking at our
code at first what code in the update block would cause that exception to be
thrown.
We can take this one step further, and use the rescue_from method
to handle the ActiveRecord::RecordNotFound exception from all of our controller
actions:
class BirdsController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response
# rest of controller...
endBy using the rescue_from method this way, if any of our controller actions
throw an ActiveRecord::RecordNotFound exception, our
render_not_found_response method will return the appropriate JSON response.
Here's the fully refactored version of the controller:
class BirdsController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response
# GET /birds
def index
birds = Bird.all
render json: birds
end
# POST /birds
def create
bird = Bird.create(bird_params)
render json: bird, status: :created
end
# GET /birds/:id
def show
bird = find_bird
render json: bird
end
# PATCH /birds/:id
def update
bird = find_bird
bird.update(bird_params)
render json: bird
end
# PATCH /birds/:id/like
def increment_likes
bird = find_bird
bird.update(likes: bird.likes + 1)
render json: bird
end
# DELETE /birds/:id
def destroy
bird = find_bird
bird.destroy
head :no_content
end
private
def find_bird
Bird.find(params[:id])
end
def bird_params
params.permit(:name, :species, :likes)
end
def render_not_found_response
render json: { error: "Bird not found" }, status: :not_found
end
endUsing exception handling techniques like rescue and rescue_from opens up a
lot of possibilities in terms of how you structure your code. For our controller
actions in particular, it allows us to isolate the "happy path" of our code
(performing CRUD actions and rendering a response to the users) from the
exception handling logic. It also lets us handle exceptions in a consistent way,
so that users of our API get the same response for common errors, like not being
able to find a particular resource.
Before you move on, make sure you can answer the following questions:
- What is the difference in behavior between the
findandfind_bymethods? Why is that difference important for how we handle not-found errors? - Looking at the final version of the controller code, what sequence of events
would happen if we tried to submit a
PATCHrequest for a bird that doesn't exist?