Building a Useful Resource Loader Plug

Alex Koppel bio photo By Alex Koppel

Editor’s note: this post was written nearly a year ago and only rediscovered today.

In my last post, I went through a very basic Elixir Plug that could ensure a record existed for a web request. This is a pretty common pattern — can’t delete a book or update a post if the ID doesn’t match an appropriate record.

As you might remember, though, the plug I built was pretty basic:

  • You could access any book because it has no access control.
  • We’d have to build duplicate the plug for other models because it hard-codes Book.
  • You can’t use more than one LoadModel plug in the same controller because it stores the results in the same place.

In this post, we’ll fix each of these problems in turn, and learn some Elixir along the way!

If you want to jump straight to the finished package, it’s published as load_resource!

The Initial Code

Here’s the initial code we’ll start from:

The Plug:

defmodule CommonplaceFeeds.LoadBook do
  # Import methods to work with requests (the conn object), such as accessing cookies or
  # stored values, triggering responses, etc.
  import Plug.Conn

  # Import methods to interact with the database
  import Ecto.Query

  # Allow us to access modules without the CommonplaceFeeds prefix
  alias CommonplaceFeeds.{Repo, Book}

  # Initialize the plugin on load
  def init(default_opts), do: default_opts

  # Handle each request that comes in (the conn object)
  def call(conn, _opts) do
    id = conn.params["id"]

    book = Repo.one(from row in Book, where: row.id == ^(id))

    if book do
      assign(conn, :book, book)
    else
      conn
      |> put_resp_content_type("application/json")
      |> send_resp(404, Poison.encode!(%{"error" => "book with id #{id} not found"}))
      |> halt
    end
  end
end

The Controller:

defmodule CommonplaceFeeds.BookController do
  use CommonplaceFeeds.Web, :controller

  plug CommonplaceFeeds.LoadBook when action in [:update, :delete]

  def delete(conn, _params) do
    result = Repo.delete(conn.assigns[:book]), conn, 200)
    render_appropriately(result)
  end
end

The Test:

    test "requires a valid book ID", %{conn: conn} do
      conn = delete conn, book_path(conn, :delete, 12345)
      assert json_response(conn, 404) == %{"error" => "book with id 12345 not found"}
    end

Access Control

Let’s start out with the most important change: make sure each user can only access their own books. We do this by passing in a param:

Changes to the Controller:

-  plug CommonplaceFeeds.LoadBook when action in [:update, :delete]
+  plug CommonplaceFeeds.LoadBook, [check_auth: true] when action in [:update, :delete]

(Note: the brackets are required.)

Right now this code assumes you’re using Guardian to manage your authentication. If not, it would be simple to swap this out for a generic handler (see What's Next below).

Changes to the Plug:

   def init(default_opts), do: default_opts

-  def call(conn, _opts) do
+  def call(conn, check_auth: check_auth) do
     id = conn.params["id"]
+    user = Guardian.Plug.current_resource(conn)

-    book = CommonplaceFeeds.Repo.one(
-      from book_row in CommonplaceFeeds.Book, where: book_row.id == ^(id)
-    )
+    base_query = from row in CommonplaceFeeds.Book, where: row.id == ^(id)
+    query = if check_auth do
+      from row in base_query, where: row.user_id == ^(user.id)
+    else
+      base_query
+    end
+
+    book = CommonplaceFeeds.Repo.one(query)

     if book do
       assign(conn, :book, book)

Changes to the Tests:

+    test "disallows access to other users' books", %{conn: conn} do
+      other_book = insert(:book, user_id: -12345)
+      conn = delete conn, book_path(conn, :delete, other_book)
+      assert json_response(conn, 404) == %{"error" => "book with id #{other_book.id} not found"}
+      assert Repo.get(Book, other_book.id)
+    end

It’s not just Books

This is a pattern we’re going to want to use in other places. The way it’s set up now we’d have to make copies of the plug if we wanted to use it in, say, a QuoteController. I bet we can fix that.

Changes to the Controller:

-  plug CommonplaceFeeds.LoadBook, [check_auth: true] when action in [:update, :delete]
+  plug CommonplaceFeeds.LoadBook, [check_auth: true, model: CommonplaceFeeds.Book] when action in [:update, :delete]

Changes to the Plug:

-defmodule CommonplaceFeeds.LoadBook do
+defmodule CommonplaceFeeds.LoadResource do
   import Plug.Conn
   import Ecto.Query
@@ -5,9 +5,9 @@
   def init(default_opts), do: default_opts

-  def call(conn, check_auth: check_auth) do
+  def call(conn, check_auth: check_auth, model: model) do
     id = conn.params["id"]
     user = Guardian.Plug.current_resource(conn)

-    base_query = from row in CommonplaceFeeds.Book, where: row.id == ^(id)
+    base_query = from row in model, where: row.id == ^(id)
     query = if check_auth do
       from row in base_query, where: row.user_id == ^(user.id)
@@ -16,12 +16,12 @@
     end

-    book = CommonplaceFeeds.Repo.one(query)
+    resource = CommonplaceFeeds.Repo.one(query)

-    if book do
-      assign(conn, :book, book)
+    if resource do
+      assign(conn, :resource, resource)
     else
       conn
       |> put_resp_content_type("application/json")
-      |> send_resp(404, Poison.encode!(%{"error" => "book with id #{id} not found"}))
+      |> send_resp(404, Poison.encode!(%{"error" => "resource with id #{id} not found"}))
       |> halt
     end

Changes to the Tests:

-      assert json_response(conn, 404) == %{"error" => "book with id 12345 not found"}
+      assert json_response(conn, 404) == %{"error" => "resource with id 12345 not found"}
@@ -31 +31 @@
-      assert json_response(conn, 404) == %{"error" => "book with id #{other_book.id} not found"}
+      assert json_response(conn, 404) == %{"error" => "resource with id #{other_book.id} not found"}

Talk about extending queries, include link.

Other ID parameter

What if the ID of the object isn’t called id?

Changes to the Controller:

-  plug CommonplaceFeeds.LoadBook, [check_auth: true, model: CommonplaceFeeds.Book] when action in [:update, :delete]
+  plug CommonplaceFeeds.LoadResource,
+        [check_auth: true, id_key: "id", model: CommonplaceFeeds.Book]
+        when action in [:update, :delete]
@@ -8 +11 @@
-    render_changeset_result(Repo.delete(conn.assigns[:book]), conn, 200)
+    render_changeset_result(Repo.delete(conn.assigns[:resource]), conn, 200)

Changes to the Plug:

-  def call(conn, check_auth: check_auth, model: model) do
-    id = conn.params["id"]
+  def call(conn, check_auth: check_auth, id_key: id_key, model: model) do
+    id = conn.params[id_key]
     user = Guardian.Plug.current_resource(conn)

The tests don’t need any changes to pass since delete book_path(conn, :delete, 12345) will now translate to book_id behind the scenes.

Resource-specific assign keys – that is, composability

This is a great single-use plug, but what if we want to have more than one in a controller? Right now both write to conn.assigns[:resource], so the second would overwrite the first 💥🤕💥

This is a real-world issue – imagine that our app not only tracks books but also quotes from within books (which it does will):

resources "/books", BookController do
  resources "/quotes/", QuoteController
end

Changes to the Controller:

   import Ecto.Query

-  def init(default_opts), do: default_opts
+  def init(default_options) do
+    model = Keyword.fetch!(default_options, :model)

-  def call(conn, check_auth: check_auth, id_key: id_key, model: model) do
+    # In order to allow us to load multiple resource for one controller, we need to have unique
+    # names for the value that gets stored on conn. To do that, we generate the name of the
+    # resource from the model name.
+    # It's safe to use Macro.underscore here because we know the text only contains characters
+    # valid for Elixir identifiers. (See https://hexdocs.pm/elixir/Macro.html#underscore/1.)
+    resource_name = String.to_atom(Macro.underscore(List.last(String.split(to_string(model), "."))))
+
+    default_options ++ [resource_name: resource_name]
+  end
+
+  def call(conn, check_auth: check_auth, id_key: id_key, model: model, handler: handler, resource_name: resource_name) do
     id = conn.params[id_key]
     user = Guardian.Plug.current_resource(conn)
@@ -19,9 +30,7 @@

     if resource do
-      assign(conn, :resource, resource)
+      assign(conn, resource_name, resource)
     else
-      conn
-      |> put_resp_content_type("application/json")
-      |> send_resp(404, Poison.encode!(%{"error" => "resource with id #{id} not found"}))
+      handler.(conn, %{"id" => id})
       |> halt
     end

Changes to the Controller:

   plug CommonplaceFeeds.LoadResource,
-        [check_auth: true, id_key: "id", model: CommonplaceFeeds.Book]
+        [check_auth: true, id_key: "id", model: CommonplaceFeeds.Book, handler: &CommonplaceFeeds.ErrorHandler.not_found/2]
         when action in [:update, :delete]
@@ -10,3 +10,3 @@
   def delete(conn, %{"id" => id}, user, _claims) do
-    render_changeset_result(Repo.delete(conn.assigns[:resource]), conn, 200)
+    render_changeset_result(Repo.delete(conn.assigns[:book]), conn, 200)
   end

No change is needed to the test for this purely internal change – the book should still continue to load unchanged.

Sane Options

Using keyword lists for pattern-matched arguments has a drawback, s I discovered after banging my head against it for 30 minutes: the list items are ordered. Transpose two of them and you’ll give yourself a FunctionClauseError. Not very usable.

Fortunately, while a plug’s options default to an empty list ([]), there’s no reason they have to stay a list. Whatever you return from init will be given to call, regardless of type. So let’s make it a map!

Changes to the Plug:

   def init(default_options) do
-    model = Keyword.fetch!(default_options, :model)
+    options = Enum.into(default_options, %{})

     # In order to allow us to load multiple resource for one controller, we need to have unique
@@ -11,10 +11,11 @@
     # It's safe to use Macro.underscore here because we know the text only contains characters
     # valid for Elixir identifiers. (See https://hexdocs.pm/elixir/Macro.html#underscore/1.)
+    model = Map.fetch!(options, :model)
     resource_name = String.to_atom(Macro.underscore(List.last(String.split(to_string(model), "."))))

-    default_options ++ [resource_name: resource_name]
+    Map.put(options, :resource_name, resource_name)
   end

-  def call(conn, check_auth: check_auth, id_key: id_key, model: model, handler: handler, resource_name: resource_name) do
+  def call(conn, %{check_auth: check_auth, id_key: id_key, model: model, handler: handler, resource_name: resource_name}) do
     id = conn.params[id_key]
     user = Guardian.Plug.current_resource(conn)

Now we can give the options in any order and it’ll work (as seen in the tests, which require no change).

What’s next?

For the moment, what’s next is simple: using the plug and see how it performs: load_resource!

The key limitation of LoadResource is that it’s not entirely flexible – there are many situations in which you might want to do something slightly different. Of course, adding more flexibility can also add more complexity, either in terms of more feature or just by removing much of the useful scaffolding the package provides.

We shall see!