Nested Resource Routing

Learning Goals

  • Understand the value of nested routes
  • Create nested routes
  • Understand how nested resource params are named

Introduction

We're going to keep working on our AirBudNB application, augmenting it to filter reviews by listing in a user-friendly and RESTful way.

To set up the app, run:

$ bundle install
$ rails db:migrate db:seed
$ rails s

URL As Data

You've encountered REST already, but, just to review, it stands for REpresentational State Transfer and encapsulates a way of structuring a URL so that access to specific resources is predictable and standardized.

In practice, that means that, if we type rails s and run our app, browsing to /reviews will show us the index of all Review objects. And if we want to view a specific DogHouse, we can guess the URL for that (as long as we know the dog house's id) by going to /dog_houses/:id.

Why do we care?

Let's imagine we added a filter feature to our reviews page:

reviews filter

When the filter is active, we could make a request to our backend, using query parameters, to retrieve only the reviews that match the selected dog house:

http://localhost:3000/reviews?doghouse=1

That's the opposite of REST. That makes me stressed. While using query params like in the link above could work, we can do better by following REST conventions.

Dynamic Route Segments

What we'd love to end up with here is something like /dog_houses/1/reviews for all of a dog house's reviews and /dog_houses/1/reviews/5 to see an individual review for that dog house.

We know we can build out a route with dynamic segments, so our first instinct might be to just define these in routes.rb like this:

# config/routes.rb
  ...
  get '/dog_houses/:dog_house_id/reviews'
  get '/dog_houses/:dog_house_id/reviews/:review_id'

After adding those routes, let's check it out by browsing to /dog_houses/1/reviews.

Oops. Error. Gotta tell those routes explicitly which controller actions will handle them. Okay, let's make it look more like this:

# config/routes.rb
  ...
  get '/dog_houses/:dog_house_id/reviews', to: 'dog_houses#reviews_index'
  get '/dog_houses/:dog_house_id/reviews/:id', to: 'dog_houses#review'

And to handle our new filtering routes, we'll need to add some code in our dog_houses_controller to actually do the work.

  # app/controllers/dog_houses_controller.rb
  ...

  def reviews_index
    dog_house = DogHouse.find(params[:dog_house_id])
    reviews = dog_house.reviews
    render json: reviews, include: :dog_house
  end

  def review
    review = Review.find(params[:id])
    render json: review, include: :dog_house
  end

Note: If your IDs are different and you are having trouble with the URLs, try running rails db:reset to reset your database.

We did it! We have much nicer URLs now. Are we done? Of course not. While this setup will work, there are a couple of problems.

First, if we look at our routes.rb, we've had to move away from using the preferred resources option and are now specifying HTTP verbs, routes, and controller actions. Given that implementing a filter is a fairly common task, this is not ideal.

Beyond that, note that our dog_houses_controller is now responsible for rendering reviews, which shouldn't be its responsibility. Furthermore, the code to find all reviews and to find individual reviews by their ID is essentially repeated in both the reviews_controller and the dog_houses_controller. Our current code is violating both the DRY (Don't Repeat Yourself) and Separation of Concerns principles.

Seems like Rails would have a way to bail us out of this mess.

Nested Resource Routes

Turns out, Rails does give us a way to make this a lot nicer.

If we look again at our models, we see that a dog house has_many :reviews and a review belongs_to :dog_house. Since a review can logically be considered a child object of a dog house, it can also be considered a nested resource of a dog house for routing purposes.

Nested resources give us a way to document that parent/child relationship in our routes and, ultimately, our URLs.

Let's get back into routes.rb, delete the two routes we just added, and recreate them as nested resources. We should end up with something like this:

# config/routes.rb

Rails.application.routes.draw do

  resources :dog_houses, only: [:show] do
    # nested resource for reviews
    resources :reviews, only: [:show, :index]
  end

  resources :reviews, only: [:show, :index, :create]
end

Now we have the resourced :dog_houses route, but by adding the do...end we can pass it a block of its nested routes.

We can still do things to the nested resources that we do to a non-nested resource, like limit them to only certain actions. In this case, we only want to nest :show and :index under :dog_houses.

Below that, we still have our regular resourced :reviews routes because we still want to let people see all reviews or a single review, create reviews, etc., outside of the context of a dog house.

You can see the routes available by running rails routes:

Prefix            Verb  URI Pattern                                     Controller#Action
dog_house_reviews GET   /dog_houses/:dog_house_id/reviews(.:format)     reviews#index
 dog_house_review GET   /dog_houses/:dog_house_id/reviews/:id(.:format) reviews#show
        dog_house GET   /dog_houses/:id(.:format)                       dog_houses#show
          reviews GET   /reviews(.:format)                              reviews#index
                  POST  /reviews(.:format)                              reviews#create

Notice, in the 'Controller#Action' column, how now we are dealing with the reviews_controller rather than the dog_houses_controller for our nested routes — our code once again reflects good Separation of Concerns. And, since we already have actions in reviews_controller to handle :show and :index, we won't be repeating ourselves like we did in the dog_houses_controller.

Now we just need to update our reviews_controller to handle the nested resource. Let's update index to account for the new routes:

# app/controllers/reviews_controller.rb

  def index
    if params[:dog_house_id]
      dog_house = DogHouse.find(params[:dog_house_id])
      reviews = dog_house.reviews
    else
      reviews = Review.all
    end
    render json: reviews, include: :dog_house
  end

We added a condition to the reviews#index action to account for whether the user is trying to access the index of all reviews (Review.all) or just the index of all reviews for a certain dog house (dog_house.reviews).

The condition hinges on whether there's a :dog_house_id key in the params hash — in other words, whether the user navigated to /dog_houses/:dog_house_id/reviews or simply /reviews. We didn't have to create any new methods or make explicit calls to render new data. We just added a simple check for params[:dog_house_id], and we're good to go.

Where is params[:dog_house_id] coming from? Rails provides it for us through the nested route, so we don't have to worry about a collision with the :id parameter that reviews#show is looking for. Rails takes the parent resource's name and appends _id to it for a nice, predictable way to find the parent resource's ID. Since some of our review routes are nested like this:

resources :dog_houses, only: [:show] do
  resources :reviews, only: [:show, :index]
end

We end up with these routes for reviews (notice the dynamic portions of the URI Patterns):

Verb  URI Pattern                                     Controller#Action
GET   /dog_houses/:dog_house_id/reviews               reviews#index
GET   /dog_houses/:dog_house_id/reviews/:id           reviews#show

You'll also notice we didn't make a single change to the reviews#show action. What about the new /dog_houses/:dog_house_id/reviews/:id route that we added?

Remember, the point of nesting our resources is to DRY up our code. We had to create a conditional for the reviews#index action because it renders different sets of reviews depending on the path, /dog_houses/:dog_house_id/reviews or /reviews. Conversely, the reviews#show route is going to render the same information — data concerning a single review — regardless of whether it is accessed via /dog_houses/:dog_house_id/reviews or /reviews/:id.

For good measure, let's go into our dog_houses_controller.rb and delete the two actions (review and reviews_index) that we added above so that it looks like this:

# app/controllers/dog_houses_controller.rb
class DogHousesController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response

  def show
    dog_house = DogHouse.find(params[:id])
    render json: dog_house
  end

  private

  def render_not_found_response
    render json: { error: "Dog house not found" }, status: :not_found
  end

end

Top-tip: Keep your application clean and easy to maintain by always removing unused code.

Caveat on Nesting Resources More Than One Level Deep

You can nest resources more than one level deep, but that is generally a bad idea.

Imagine if we also had comments on a review. This would be a perfectly fine use of nesting:

resources :reviews do
  resources :comments
end

We could then access a reviews's comments with /reviews/1/comments. That makes a lot of sense.

But if we then tried to add to our already nested reviews resource...

resources :dog_houses do
  resources :reviews do
    resources :comments
  end
end

Now we're getting into messy territory. Our URL is /dog_houses/1/reviews/1/comments, and we have to handle that filtering in our controller.

But if we lean on our old friend Separation of Concerns, we can conclude that a reviews's comments are not the concern of a dog house and therefore don't belong nested two levels deep under the :dog_houses resource.

In addition, the reason to put the ID of the resource in the URL is so that we have access to it in the controller. If we know we have the review with an ID of 1, we can use our Active Record relationships to call:

  review = Review.find(params[:id])
  review.dog_house
  # This will tell us which dog house the review was for!
  # We don't need this information in the URL

Conclusion

Nesting resources is a powerful tool that helps you keep your routes neat and tidy and is better than dynamic route segments for representing parent/child relationships in your system.

However, as a general rule, you should only nest resources one level deep and ensure that you are considering Separation of Concerns in your routing.

Check For Understanding

Before you move on, make sure you can answer the following questions:

  1. What are the benefits of using nested routes?
  2. How do we distinguish nested routes from parent routes in our routes.rb file?

Resources