Composing and mixing Phoenix plugs

SeriesPhoenix on Rails clock4 min read

Plugs make a serial routing chain, but it's very easy to mix them arbitrarily. Here's a cool backdoor example how.

Quoting Phoenix documentation:

Plug is a specification for composable modules in between web applications. 

Plugs are main building blocks for Phoenix endpoints, routers and controllers. Calling them with the plug macro composes a chain that each connection will have to go through. Sometimes however you may need more flexibility and calling plugs in default serial fashion won't suffice.

Plugs are composable because Elixir is composable. Because of that, you can compose them in arbitrary fashion easily by calling them directly instead of using the plug macro. And because there's no context, instance evaluation or hidden state, it really is as simple as calling appropriate plug functions just the way Plug does. It may be trivial for experienced Elixir/Phoenix devs, but for object-oriented newcomers it may require switching brain into functional mode.

Function plugs

All plugs, may it be a single function plug or a module plug, take two parameters: a connection and options that configure the specific plug call. In case of function plugs, it's all in just one function so it's easy enough to call it within whatever custom flow you may need. Let's say you've started with a simple admin layout setter in your admin pipeline:

lang:elixir
plug :put_layout, "admin.html"

But now that your app has became a hit in Germany, you need to provide that layout in English and German, depending on locale set in cookie. Here's how to use that same plug dynamically:

lang:elixir
# in the pipeline
plug :put_admin_layout, default_locale: "en"

# in a module imported into router
def put_admin_layout(conn, options) do
  locale = conn.cookies["locale"] || options[:default_locale]

  put_layout conn, "admin.#{locale}.html"
end

Module plugs

In case of a module plug, it's a just a little more complicated. Basically, you'll have not one but two functions that you need to call in place of the plug macro: init and call.

Here's a short primer on how these work. First, the init function is called with passed options in order to preprocess them and perhaps prepare some data needed for the plug to work. Being called at compile time, it saves you some time on each connection and throws early in case of problems. Then, call is executed with those initialized options to process each connection.

Let's use that to implement a simple yet cool backdoor into admin panel, that may come handy when app goes live and the client runs into some problems that we'll have to inspect. We'll use a basic_auth plug for authentication. But as we don't want basic auth to be a default authentication mechanics for admin panel, we'll only trigger it when our secret param is set.

And here's the fearsome plug:

lang:elixir
defmodule FancyApp.Admin.Backdoor do
  import Plug.Conn, only: [ assign: 3 ]

  def init(opts) do
    [:param, :password, :username] = Keyword.keys(opts)
                                     |> Enum.sort

    basic_auth_opts = BasicAuth.init(username: opts[:username],
                                     password: opts[:password],
                                     realm: "admin")

    [ param: opts[:param], basic_auth_opts: basic_auth_opts ]
  end

  def call(conn, opts) do
    case conn.params[opts[:param]] do
      nil ->
        conn

      _ ->
        conn
        |> BasicAuth.call(opts[:basic_auth_opts])
        |> put_backdoor_admin
    end
  end

  defp put_backdoor_admin(%{ halted: true } = conn), do: conn
  defp put_backdoor_admin(conn), do: assign(conn, :admin_access, true)
end

In init, we verify that all required params were indeed passed and collect processed options from BasicAuth.init for future use. This covers calling init on behalf of plug macro.

In call, we check if our secret param was passed. If it was, we run BasicAuth.call with previously stored basic_auth options. Then we use a little pattern matching magic in order to set the :admin_access assign to true but only if basic authentication has succeeded. You could replace that assign call with fetching admin model from the repo, setting an admin cookie or whatever else is needed to replicate the main admin authentication mechanics.

Summary

As you can see, plugs are easily composable in any way you'd like. You can compose them into chains with the plug macro or you can call them from within your own code in order to customize their flow. But it's no surprise - Plug is just a small convention built on powerful composability and transparency of Elixir itself. 

I expect this transparency and ease of hooking into anything including core parts of Phoenix to be a nice, refreshing change for anyone who has ever tried to write Rails middleware or engine.