On a Phoenix application I worked on recently, I decided to tackle a bug
where we weren’t redirecting users to a sign-in page even though we were
expecting conn.assigns
to have current_user
. This was only happening in
a few different areas of the app. Plugs to the rescue.
What are Plugs?
Per the Plug GitHub page, a plug is a “specification for composable modules between web applications.”
Although some might compare Plugs to Rack middleware, they operate on the entire request and response lifecycle; in fact, Endpoints, Routers, and Controllers within Phoenix are all Plugs internally.
Writing a Small Plug
Let’s dig into a plug that has the responsibility described above. There should be two paths through the plug:
- The connection has a
current_user
and should continue - The connection does not have a
current_user
and should stop everything and redirect the user to the sign-in page
Let’s create a new file in web/plugs/require_login.ex
:
defmodule MyApp.Plugs.RequireLogin do
import Plug.Conn
def init(opts), do: opts
def call(conn, _) do
conn
end
end
This is a minimal interface for a module plug. call
is where we’ll handle
letting the connection continue or halting execution; let’s flesh out the
paths now:
# web/plugs/require_login.ex
def call(conn, _) do
if conn.assigns[:current_user] do
# everything is good
else
# uh-oh - ask the user to sign in
end
end
In our “happy path” when a current_user
is set, we’ll need to pass the
conn
through so it can continue on its merry way.
When conn.assigns[:current_user]
doesn’t return a truthy value, we’ll want
to redirect them to "/sign_in"
, where we’ll prompt them to sign in. Also note that
we’re accessing current_user
via []
; we do so instead of
conn.assigns.current_user
because if current_user
hasn’t been assigned,
the application will raise a KeyError
. Check out the Elixir documentation on
the Access
behaviour if you want to learn more.
# web/plugs/require_login.ex
def call(conn, _) do
if conn.assigns[:current_user] do
conn
else
conn |> Phoenix.Controller.redirect(to: "/sign_in")
end
end
Because of the nature of Plugs, we’re letting Phoenix.Controller.redirect/2
do
the heavy lifting for us.
Halt!
If we used the code above, we’d begin to see problems. As mentioned
previously, because Plugs cover the entire lifecycle of the connection,
calling Phoenix.Controller.redirect/2
by itself actually isn’t enough; we
also need to call Plug.Conn.halt/1
to stop further execution and immediately
process the redirect.
Let’s refactor a bit before we move on:
defmodule MyApp.Plugs.RequireLogin do
import Plug.Conn
def init(opts), do: opts
def call(conn, _) do
if conn.assigns[:current_user] do
conn
else
conn |> redirect_to_login
end
end
defp redirect_to_login(conn) do
conn |> Phoenix.Controller.redirect(to: "/sign_in") |> halt
end
end
Testing the Plug
Because we don’t need to build up or manage complicated state for our connection, and because our functions allow for known inputs and outputs, testing the plug is fairly painless.
Let’s start with a new test:
# test/plugs/require_login_test.exs
defmodule MyApp.Plugs.RequireLoginTest do
use MyApp.ConnCase
test "user is redirected when current_user is not assigned" do
# build a connection and run the plug
assert redirected_to(conn) == "/sign_in"
end
test "user passes through when current_user is assigned" do
# build a connection, assign current_user, and run the plug
assert conn.status != 302
end
end
We’ll use MyApp.ConnCase
, as that will give us access to various
functions to interact with a connection. To get the first test passing, we’ll
need to generate a connection and then run our plug.
# test/plugs/require_login_test.exs
test "user is redirected when current_user is not assigned" do
conn = conn() |> MyApp.Plugs.RequireLogin.call(%{})
assert redirected_to(conn) == "/sign_in"
end
We can generate a connection with Phoenix.ConnTest.conn/0
and then pipe
that connection to our plug. This test should be green.
For our second test, we’ll need to introduce another step where we assign
current_user
.
# test/plugs/require_login_test.exs
test "user passes through when current_user is set" do
conn =
conn()
|> assign(:current_user, %MyApp.User{})
|> MyApp.Plugs.RequireLogin.call(%{})
assert conn.status != 302
end
Whew. Finally, a bit of refactoring leaves us with:
defmodule MyApp.Plugs.RequireLoginTest do
use MyApp.ConnCase
test "user is redirected when current_user is not set" do
conn = conn() |> require_login
assert redirected_to(conn) == "/sign_in"
end
test "user passes through when current_user is set" do
conn = conn() |> authenticate |> require_login
assert not_redirected?(conn)
end
defp require_login(conn) do
conn |> MyApp.Plugs.RequireLogin.call(%{})
end
defp authenticate(conn) do
conn |> assign(:current_user, %MyApp.User{})
end
defp not_redirected?(conn) do
conn.status != 302
end
end
Of note is the not_redirected?/1
function here; the reason we assert that
the status is “not a 302
” is because Phoenix.ConnTest.conn/0
doesn’t
actually set a status
and leaves the value as nil
. assert conn.status ==
nil
seemed less intuitive than asserting a redirect did not occur.
Plugs and the Router
With the plug working and tested, using it in the router is almost anticlimactic.
# web/router.ex
defmodule MyApp.Router do
use Phoenix.Router
pipeline :browser do
plug :accepts, ~w(html)
# ...
end
# endpoints not requiring a logged in user
scope "/", MyApp do
pipe_through :browser
# ... resources
end
# endpoints requiring a logged in user
scope "/", MyApp do
pipe_through [:browser, MyApp.Plugs.RequireLogin]
# ... resources
end
end
This introduces a new scope
block where we pass a list to
Phoenix.Router.pipe_through/1
, and then declare all resources requiring
current_user
within that block. These plugs are executed sequentially, so it
will first run through the :browser
pipeline, and then through our plug.
Explore Other Plugs
I hope this was a helpful guide in authoring and unit-testing your own Elixir plugs; testing plugs in isolation can be daunting if you’ve never done it before. If you’re looking for other inspiration, I encourage you to look at the tests written for Plug itself to understand different approaches you can take.