Phoenix: Using Plugs to improve scoped resources

In this post I’m going to talk about creating a Phoenix web app. The application intends to create a scoped route with nested resources. If you don’t know what any of that means then this post probably isn’t for you.

The Goal

I want to create an application that supports a number of resources under a project scoping. I want every project to support the following:

  1. Users
  2. Posts + Comments
  3. Events

Since all of these resources belong to the project scope, we need to create a sensible URL structure. Something like this should suffice:

/projects/{project_id}/users
/projects/{project_id}/users/{user_id}
/projects/{project_id}/posts
/projects/{project_id}/posts/{post_id}/comments
/projects/{project_id}/events
/projects/{project_id}/events/{event_id}

I’ve omitted some routes, but hopefully you get the point. Let’s jump in.

Scoped Routes

Firstly we need to update our router to support our project scoping. Fortunately Phoenix supports Scoped Routes out of the box, so a straightforward change to our router will add this for us:

scope "/projects/:project_id" do
  get "/", ProjectController, :show

  # resources here
end

Now if we run mix phx.routes we’ll see our project scoping:

project_path  GET  /projects/:project_id  AppWeb.ProjectController :show

Nested Resources

Next we’ll need to add the resources inside of our scoping to complete our URL structure:

scope "/projects/:project_id" do
  get "/", ProjectController, :show

  resources "/users", UserController
  resources "/events", EventController
  resources "/posts", PostController do
    resources "/comments", CommentController
  end
end

And mix phx.routes:

     project_path  GET     /projects/:project_id                                   AppWeb.ProjectController :show
        user_path  GET     /projects/:project_id/users                             AppWeb.UserController :index
        user_path  GET     /projects/:project_id/users/:id/edit                    AppWeb.UserController :edit
        user_path  GET     /projects/:project_id/users/new                         AppWeb.UserController :new
        user_path  GET     /projects/:project_id/users/:id                         AppWeb.UserController :show
        user_path  POST    /projects/:project_id/users                             AppWeb.UserController :create
        user_path  PATCH   /projects/:project_id/users/:id                         AppWeb.UserController :update
                   PUT     /projects/:project_id/users/:id                         AppWeb.UserController :update
        user_path  DELETE  /projects/:project_id/users/:id                         AppWeb.UserController :delete
       event_path  GET     /projects/:project_id/events                            AppWeb.EventController :index
       event_path  GET     /projects/:project_id/events/:id/edit                   AppWeb.EventController :edit
       event_path  GET     /projects/:project_id/events/new                        AppWeb.EventController :new
       event_path  GET     /projects/:project_id/events/:id                        AppWeb.EventController :show
       event_path  POST    /projects/:project_id/events                            AppWeb.EventController :create
       event_path  PATCH   /projects/:project_id/events/:id                        AppWeb.EventController :update
                   PUT     /projects/:project_id/events/:id                        AppWeb.EventController :update
       event_path  DELETE  /projects/:project_id/events/:id                        AppWeb.EventController :delete
        post_path  GET     /projects/:project_id/posts                             AppWeb.PostController :index
        post_path  GET     /projects/:project_id/posts/:id/edit                    AppWeb.PostController :edit
        post_path  GET     /projects/:project_id/posts/new                         AppWeb.PostController :new
        post_path  GET     /projects/:project_id/posts/:id                         AppWeb.PostController :show
        post_path  POST    /projects/:project_id/posts                             AppWeb.PostController :create
        post_path  PATCH   /projects/:project_id/posts/:id                         AppWeb.PostController :update
                   PUT     /projects/:project_id/posts/:id                         AppWeb.PostController :update
        post_path  DELETE  /projects/:project_id/posts/:id                         AppWeb.PostController :delete
post_comment_path  GET     /projects/:project_id/posts/:post_id/comments           AppWeb.CommentController :index
post_comment_path  GET     /projects/:project_id/posts/:post_id/comments/:id/edit  AppWeb.CommentController :edit
post_comment_path  GET     /projects/:project_id/posts/:post_id/comments/new       AppWeb.CommentController :new
post_comment_path  GET     /projects/:project_id/posts/:post_id/comments/:id       AppWeb.CommentController :show
post_comment_path  POST    /projects/:project_id/posts/:post_id/comments           AppWeb.CommentController :create
post_comment_path  PATCH   /projects/:project_id/posts/:post_id/comments/:id       AppWeb.CommentController :update
                   PUT     /projects/:project_id/posts/:post_id/comments/:id       AppWeb.CommentController :update
post_comment_path  DELETE  /projects/:project_id/posts/:post_id/comments/:id       AppWeb.CommentController :delete

Excellent, this is exactly what we want. Let’s take a peek at our comments controller:

defmodule AppWeb.CommentController do
  use AppWeb, :controller

  def index(conn, %{"project_id" => project_id, "post_id" => post_id}) do
    render(conn, "index.html", comments: list_comments(project_id, post_id))
  end

  def show(conn, %{"project_id" => project_id, "post_id" => post_id, "id" => comment_id}) do
    render(conn, "show.html", comment: get_comment!(project_id, post_id, comment_id))
  end
end

OK, it’s not bad, but one thing is very clear: every one of our controller actions are going to need to handle this project_id parameter.

I come from a Rails background, and the canonical way to solve this in Rails is to add a before_action:

class CommentController
  before_action :set_project

  # actions

  def set_project
    @project = Project.find(params[:project_id])
  end
end

We can’t do this in Phoenix.

Enter Plug

However, Phoenix supports Plug. Plug is an Elixir library that implements a specification for composable modules to be used in web applications. Phoenix uses Plug heavily under the hood (in fact, Phoenix controllers themselves implement the Plug behaviour).

Our Phoenix controllers expose a helpful function named plug that allows us to implement behaviour similar to our before_action:

defmodule AppWeb.CommentController do
  use AppWeb, :controller

  plug :put_project

  def show(conn, %{"post_id" => post_id, "id" => comment_id}) do
    %{current_project: project} = conn.assigns
    render(conn, "show.html", comment: get_comment!(project, post_id, comment_id))
  end

  defp put_project(conn, _opts) do
    current_project = fetch_current_project(conn.params["project_id"])
    assign(conn, :current_project, current_project)
  end
end

This code is pretty straightforward. put_project/2 is called before our action is executed, and we put the current project into conn.assigns (a storage mechanism provided to us by Plug). Now we can use this function in all of our project-scoped controllers to remove the %{"project_id" => project_id} matches.

This is nice, but we can go one step further and move this functionality into our router using pipelines.

Pipelines

Phoenix supports something called Pipelines. Pipelines allow us to attach a series of plugs to a scope. Let’s add a new pipeline for our project scoping:

pipeline :project do
  # plug :authenticate_user
  plug AppWeb.CurrentProject
end

scope "/projects/:project_id" do
  pipe_through :project

  get "/", ProjectController, :show

  # ...
end

And define AppWeb.CurrentProject like so:

defmodule AppWeb.CurrentProject do
  @moduledoc """
  This module implements functionality to fetch the current project
  from the URL and add it to Conn.assigns, making it available to any
  controller within the project scope.
  """

  @behaviour Plug

  import Plug.Conn
  import Phoenix.Controller

  @assigns_key :current_project

  def init(opts), do: opts

  def call(%Plug.Conn{params: %{"project_id" => id}} = conn, _opts) do
    assign(conn, @assigns_key, get_project!(id))
  end

  defp get_project!(id) do
    # fetch project from database
  end
end

There we have it. All of our project-scoped controllers will be able to fetch the current project from conn.assigns without having to add any controller-specific code.

Bonus: Nested Layouts

By default, Phoenix wraps all of our views in the layout defined in templates/layout/app.html.eex. This layout template contains an assign named @inner_content which, as you’d expect, returns the content of our view templates.

I want users to see a familiar project-based UI in all of our scoped pages. I don’t want to have to create a new app layout because it’s going to contain a lot of duplicate code. Similarly, I don’t want to have to add a bunch of conditional statements inside of app.html.eex that add content based on whether we’re inside the project-scoping.

What I really want is a nested layout: app.html.eex > project.html.eex > our view template.

Thankfully Phoenix has our backs on this and provides Phoenix.Controller.put_root_layout/2

Let’s tweak our AppWeb.CurrentProject plug:

def call(%Plug.Conn{params: %{"project_id" => id}} = conn, _opts) do
  conn
  |> put_layout({AppWeb.ProjectView, "layout.html"})
  |> put_root_layout({AppWeb.LayoutView, "app.html"})
  |> assign(@assigns_key, get_project!(id))
end

defp get_project!(id)
  # fetch project from database
end

And create a new file in templates/project/layout.html.eex with the following content:

<h1><%= @current_project.name %></h1>
<div class="project-content">
  <%= @inner_content %>
</div>

And that’s it. All of our project-scopes view templates will be rendered inside of this layout.


Have any suggestions for improving this post? Let me know.