Subscribe now

Nested Resources in Phoenix [05.01.2017]

In the last episode, we introduced categories and looked at the frontend a bit in our new Phoenix application, setting up a good starting point for the CSS. Today we'll continue to build out the Firestorm Forum, by adding threads to categories. In the process, we'll also add nested routing, on the path to SEO-friendly URLs. Let's get started.

Project

We're starting with the dailydrip/firestorm repo tagged before this episode. We'd like to add Threads underneath a Category. Let's start by generating the resource:

mix phx.gen.html Forums Thread threads title category_id:references:forums_categories

We'll add a route:

vim lib/firestorm_web/web/router.ex
defmodule FirestormWeb.Web.Router do
  # ...
  scope "/", FirestormWeb.Web do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
    resources "/users", UserController
    resources "/categories", CategoryController
    resources "/threads", ThreadController
  end
end

And we'll run the migration:

mix ecto.migrate

Right now our threads are at the top level though, not nested underneath their categories. We can ask Phoenix to show us our routes:

mix phx.routes
    page_path  GET     /                     FirestormWeb.Web.PageController :index
    user_path  GET     /users                FirestormWeb.Web.UserController :index
    user_path  GET     /users/:id/edit       FirestormWeb.Web.UserController :edit
    user_path  GET     /users/new            FirestormWeb.Web.UserController :new
    user_path  GET     /users/:id            FirestormWeb.Web.UserController :show
    user_path  POST    /users                FirestormWeb.Web.UserController :create
    user_path  PATCH   /users/:id            FirestormWeb.Web.UserController :update
               PUT     /users/:id            FirestormWeb.Web.UserController :update
    user_path  DELETE  /users/:id            FirestormWeb.Web.UserController :delete
category_path  GET     /categories           FirestormWeb.Web.CategoryController :index
category_path  GET     /categories/:id/edit  FirestormWeb.Web.CategoryController :edit
category_path  GET     /categories/new       FirestormWeb.Web.CategoryController :new
category_path  GET     /categories/:id       FirestormWeb.Web.CategoryController :show
category_path  POST    /categories           FirestormWeb.Web.CategoryController :create
category_path  PATCH   /categories/:id       FirestormWeb.Web.CategoryController :update
               PUT     /categories/:id       FirestormWeb.Web.CategoryController :update
category_path  DELETE  /categories/:id       FirestormWeb.Web.CategoryController :delete
  thread_path  GET     /threads              FirestormWeb.Web.ThreadController :index
  thread_path  GET     /threads/:id/edit     FirestormWeb.Web.ThreadController :edit
  thread_path  GET     /threads/new          FirestormWeb.Web.ThreadController :new
  thread_path  GET     /threads/:id          FirestormWeb.Web.ThreadController :show
  thread_path  POST    /threads              FirestormWeb.Web.ThreadController :create
  thread_path  PATCH   /threads/:id          FirestormWeb.Web.ThreadController :update
               PUT     /threads/:id          FirestormWeb.Web.ThreadController :update
  thread_path  DELETE  /threads/:id          FirestormWeb.Web.ThreadController :delete

We can put threads underneath categories in the router.

vim lib/firestorm_web/web/router.ex
defmodule FirestormWeb.Web.Router do
  # ...
  scope "/", FirestormWeb.Web do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
    resources "/users", UserController
    resources "/categories", CategoryController do
      resources "/threads", ThreadController
    end
  end
end

If we try to look at the routes now, though, the application won't compile. That's because the generated files reference path helper functions that no longer exist. We'll have to fix these manually. First, we can fix the ThreadController. Now we'll see a path like /categories/1/threads, so we'll want to load the category when we start an action in the ThreadController. We can do this by overriding the action function in our controller:

  def action(conn, _) do
    category = Forums.get_category!(conn.params["category_id"])
    args = [conn, conn.params, category]
    apply(__MODULE__, action_name(conn), args)
  end

Now, our controller actions will receive the category as the third argument. There's a lot going on here that I just glossed over. There was an existing, overridable function named action/2 on this controller module. Normally, it would call the appropriate action on the controller with 2 arguments - conn and conn.params. We overrode that function, and told it to add a third argument when calling the controller's actions. That third argument is the category that we fetch based on the category id that is parsed from the route. It might seem a bit confusing, but I actually think it's really powerful that we're able to do this sort of thing.

Now, though, we need to modify each of our controller's actions to take this third argument, and we need to modify any use of the non-nested route. We'll also modify the context to require us to pass the category in for various functions dealing with threads.

First, we'll work on the index action:

defmodule FirestormWeb.Web.ThreadController do
  # ...
  # We include the category as the third argument...
  def index(conn, _params, category) do
    # we pass it to the `list_threads` function
    threads = Forums.list_threads(category)
    # and we pass it in our call to render, so templates can refer to it as
    # @category.
    render(conn, "index.html", threads: threads, category: category)
  end
  # ...
end
defmodule FirestormWeb.Forums do
  # ...
  alias FirestormWeb.Forums.Thread

  @doc """
  Returns the list of threads for a given category.

  ## Examples

      iex> list_threads(category)
      [%Thread{}, ...]

  """
  # We'll take the category as an argument and restrict ourselves to threads
  # from this category.
  def list_threads(category) do
    Thread
    |> where([t], t.category_id == ^category.id)
    |> Repo.all
  end
  # ...
end

Then we'll move on to new, setting the category_id on the changeset from our fetched category.

defmodule FirestormWeb.Web.ThreadController do
  # ...
  def new(conn, _params, category) do
    changeset =
      %Thread{category_id: category.id}
      |> Forums.change_thread
    render(conn, "new.html", changeset: changeset, category: category)
  end
  # ...
end

We'll do the same thing with the params that come in for create:

defmodule FirestormWeb.Web.ThreadController do
  # ...
  def create(conn, %{"thread" => thread_params}, category) do
    thread_params =
      thread_params
      |> Map.put("category_id", category.id)

    case Forums.create_thread(thread_params) do
      {:ok, thread} ->
        conn
        |> put_flash(:info, "Thread created successfully.")
        |> redirect(to: category_thread_path(conn, :show, category, thread))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset, category: category)
    end
  end
  # ...
end

We also need to update the context to let us set the category_id when we create a thread:

defmodule FirestormWeb.Forums do
  # ...
  defp thread_changeset(%Thread{} = thread, attrs) do
    thread
    |> cast(attrs, [:title, :category_id])
    |> validate_required([:title, :category_id])
  end
  # ...
end

We'll update show and require you to pass in the category you're fetching a thread for if you use the Forums.get_thread! function:

defmodule FirestormWeb.Web.ThreadController do
  # ...
  def show(conn, %{"id" => id}, category) do
    thread = Forums.get_thread!(category, id)
    render(conn, "show.html", thread: thread, category: category)
  end
  # ...
end
defmodule FirestormWeb.Forums do
  # ...
  @doc """
  Gets a single thread in a category.

  Raises `Ecto.NoResultsError` if the Thread does not exist in that category.

  ## Examples

      iex> get_thread!(category, 123)
      %Thread{}

      iex> get_thread!(category, 456)
      ** (Ecto.NoResultsError)

  """
  def get_thread!(category, id) do
    Thread
    |> where([t], t.category_id == ^category.id)
    |> Repo.get!(id)
  end
  # ...
end

We'll tweak edit:

defmodule FirestormWeb.Web.ThreadController do
  # ...
  def edit(conn, %{"id" => id}, category) do
    thread = Forums.get_thread!(category, id)
    changeset = Forums.change_thread(thread)
    render(conn, "edit.html", thread: thread, changeset: changeset, category: category)
  end
  # ...
end

Then update:

defmodule FirestormWeb.Web.ThreadController do
  # ...
  def update(conn, %{"id" => id, "thread" => thread_params}, category) do
    thread = Forums.get_thread!(category, id)

    case Forums.update_thread(thread, thread_params) do
      {:ok, thread} ->
        conn
        |> put_flash(:info, "Thread updated successfully.")
        |> redirect(to: category_thread_path(conn, :show, category, thread))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "edit.html", thread: thread, changeset: changeset, category: category)
    end
  end
  # ...
end

And delete:

defmodule FirestormWeb.Web.ThreadController do
  # ...
  def delete(conn, %{"id" => id}, category) do
    thread = Forums.get_thread!(category, id)
    {:ok, _thread} = Forums.delete_thread(thread)

    conn
    |> put_flash(:info, "Thread deleted successfully.")
    |> redirect(to: category_thread_path(conn, :index, category))
  end
  # ...
end

If you'll remember, when we started all of this we just wanted to look at the new routes. Let's try again:

mix phx.routes

We still can't see them, because we're using the old route helpers in some views still. Let's fix that. First, the new template:

vim lib/firestorm_web/web/templates/thread/new.html.eex
<h2>New Thread</h2>

<%= render "form.html", changeset: @changeset,
                        action: category_thread_path(@conn, :create, @category) %>

<span><%= link "Back", to: category_thread_path(@conn, :index, @category) %></span>

Then edit:

vim lib/firestorm_web/web/templates/thread/edit.html.eex
<h2>Edit Thread</h2>

<%= render "form.html", changeset: @changeset,
                        action: category_thread_path(@conn, :update, @category, @thread) %>

<span><%= link "Back", to: category_thread_path(@conn, :index, @category) %></span>

Then index:

vim lib/firestorm_web/web/templates/thread/index.html.eex
<h2>Listing Threads</h2>

<table class="table">
  <thead>
    <tr>
      <th>Title</th>

      <th></th>
    </tr>
  </thead>
  <tbody>
<%= for thread <- @threads do %>
    <tr>
      <td><%= thread.title %></td>

      <td class="text-right">
        <span><%= link "Show", to: category_thread_path(@conn, :show, @category, thread), class: "btn btn-default btn-xs" %></span>
        <span><%= link "Edit", to: category_thread_path(@conn, :edit, @category, thread), class: "btn btn-default btn-xs" %></span>
        <span><%= link "Delete", to: category_thread_path(@conn, :delete, @category, thread), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-danger btn-xs" %></span>
      </td>
    </tr>
<% end %>
  </tbody>
</table>

<span><%= link "New Thread", to: category_thread_path(@conn, :new, @category) %></span>

And finally show:

vim lib/firestorm_web/web/templates/thread/show.html.eex
<h2>Show Thread</h2>

<ul>

  <li>
    <strong>Title:</strong>
    <%= @thread.title %>
  </li>

</ul>

<span><%= link "Edit", to: category_thread_path(@conn, :edit, @category, @thread) %></span>
<span><%= link "Back", to: category_thread_path(@conn, :index, @category) %></span>

Now, with all of that out of the way, we can finally see our routes:

mix phx.routes
           page_path  GET     /                                          FirestormWeb.Web.PageController :index
           user_path  GET     /users                                     FirestormWeb.Web.UserController :index
           user_path  GET     /users/:id/edit                            FirestormWeb.Web.UserController :edit
           user_path  GET     /users/new                                 FirestormWeb.Web.UserController :new
           user_path  GET     /users/:id                                 FirestormWeb.Web.UserController :show
           user_path  POST    /users                                     FirestormWeb.Web.UserController :create
           user_path  PATCH   /users/:id                                 FirestormWeb.Web.UserController :update
                      PUT     /users/:id                                 FirestormWeb.Web.UserController :update
           user_path  DELETE  /users/:id                                 FirestormWeb.Web.UserController :delete
       category_path  GET     /categories                                FirestormWeb.Web.CategoryController :index
       category_path  GET     /categories/:id/edit                       FirestormWeb.Web.CategoryController :edit
       category_path  GET     /categories/new                            FirestormWeb.Web.CategoryController :new
       category_path  GET     /categories/:id                            FirestormWeb.Web.CategoryController :show
       category_path  POST    /categories                                FirestormWeb.Web.CategoryController :create
       category_path  PATCH   /categories/:id                            FirestormWeb.Web.CategoryController :update
                      PUT     /categories/:id                            FirestormWeb.Web.CategoryController :update
       category_path  DELETE  /categories/:id                            FirestormWeb.Web.CategoryController :delete
category_thread_path  GET     /categories/:category_id/threads           FirestormWeb.Web.ThreadController :index
category_thread_path  GET     /categories/:category_id/threads/:id/edit  FirestormWeb.Web.ThreadController :edit
category_thread_path  GET     /categories/:category_id/threads/new       FirestormWeb.Web.ThreadController :new
category_thread_path  GET     /categories/:category_id/threads/:id       FirestormWeb.Web.ThreadController :show
category_thread_path  POST    /categories/:category_id/threads           FirestormWeb.Web.ThreadController :create
category_thread_path  PATCH   /categories/:category_id/threads/:id       FirestormWeb.Web.ThreadController :update
                      PUT     /categories/:category_id/threads/:id       FirestormWeb.Web.ThreadController :update
category_thread_path  DELETE  /categories/:category_id/threads/:id       FirestormWeb.Web.ThreadController :delete

We have threads nested under categories, in theory!. This is one of the reasons that I don't love using generators - it took us a long time to get the project into a usable state after the fact. Still, they're very nice when they work for what you're doing.

We can create a new thread under a category by visiting any category and adding /threads to the URL: http://localhost:4000/categories/1/threads. Let's make sure it works.

Summary

In today's episode, we created a nested resource. In the process of modifying our generated Thread resource to be nested underneath its Category, we've broken the tests. That's terrible. Tomorrow, we'll have an exercise to clean those up. See you soon!

Resources