Consistent preloading & auth in an app driven by Phoenix LiveView
Here’s how I came with an abstraction for consistently running initialization & authorization code in Pubray — an app that’s (almost) 100% routed & served via websockets without reloading the page. And how it turned out in practice after a year of heavy development.
Making an entire Phoenix app “live”
As explained in the previous article, I’ve picked Phoenix LiveView as a bleeding edge solution to build Pubray with interactions and real-time features. And fast. For starters, I wanted no full page reloads and then I wanted server-driven UI magic sprinkled wherever I wanted.
However, at the very beginning I saw a missing piece in this seemingly genius plan. You see, despite Phoenix LiveView library providing a complete support for routing (even multiple actions) to live views — just like regular Phoenix controllers do — it expresses code with focus on stateful lifecycle and not composition & chaining of the “before code” and picking the right one based on specific action.
I totally get the reasoning behind this design and take it as a necessity for a core library that works around long-lived state. It’s purely functional and OTP-compliant. And it’s great when using live views just for those specific places in a project that really need it — as a “spec-ops tech”.
At the same time, we could use live views globally to replace what would be tens of Phoenix controllers — like Phoenix 1.5 invites us to with --live
generators and like I dared to do with Pubray. In such case, we’ll have to facilitate something that’s very common in all web apps and that falls somewhere between the router and controller in most MVC frameworks — action-specific initialization and authorization code.
This means code that does things like:
- loading up-to-date version of currently logged in user
- loading globally or widely used resource e.g. notification status
- loading tenant in multi-tenant/SaaS apps
- setting up locale, timezone etc
- ensuring user is (still) logged in as required by some actions
- ensuring user is (still) allowed to see a page or execute an action
- providing answers for what happens if any of these go wrong
In classic Phoenix apps, these chores are mostly handled by Plug. Most of other web frameworks have similar answer to such common and global needs — a DSL to express them in a simple, pragmatic, readable and maintainable way. For me, filling an entire web codebase with the same case
/with
blocks in mounts & event handlers + pattern-matches on socket.assigns.live_action
just wasn’t going to cut it.
Btw, I’m all in for explicit code and I tend to pick pure functions over DSLs (as evidenced here 😃). But here DSL wins because preloading and auth is often more like a policy than imperative code.
New abstraction — now or never
As far as my experience tells, these kinds of abstractions must be introduced from the very start as it’s not feasible to e.g. rewrite auth logic later on. Time invested into refactoring is one thing, but I doubt that there’s any engineer working on a mature production app that wouldn’t find turning such code upside down at least… slightly uncomfortable (not mentioning his product owner — “if it ain’t broke…”).
That’s why before kicking off Pubray development I’ve created my own abstraction over LiveView - Phoenix.LiveController. I won’t go repeating the basics since it’s well documented on HexDocs and in my release article. Instead, I’d like to present some real-life excerpts from Pubray and conclude how it turned out after using live controllers for more than a year.
Global hooks
For starters, live plugs allowed me to facilitate all the global loading code outside of actions and event handlers. I’ve put that directly in PubrayWeb
, simply creating another case for using this module:
defmodule PubrayWeb do
# ...
def live_controller do
quote do
use Phoenix.LiveController, layout: {PubrayWeb.LayoutView, "live.html"}
use PubrayWeb.LiveTelemetry
unquote(view_helpers())
unquote(live_controller_global_plugs())
end
end
end
Then, I’ve implemented live_controller_global_plugs
whose single responsibility is to provide live controller plugs that will be injected to all such controllers:
defmodule PubrayWeb do
# ...
defp live_controller_global_plugs do
alias PubrayWeb.{LiveSpaceHelpers, LiveUserAuth, LiveLocale, LiveLayoutHelpers, LiveTimezone}
quote do
plug LiveSpaceHelpers.fetch_space(socket, params) when not mounted?(socket)
plug LiveSpaceHelpers.redirect_space_link(socket, params) when not mounted?(socket)
plug {LiveUserAuth, :fetch_current_user} when not mounted?(socket)
plug {LiveLocale, :set_locale} when not mounted?(socket)
plug {LiveTimezone, :set_timezone} when not mounted?(socket)
plug {LiveLayoutHelpers, :check_if_static_changed} when not mounted?(socket)
plug {LiveSpaceHelpers, :fetch_personal_space} when not mounted?(socket)
end
end
end
Now, it takes just a single line (and one that’s consistent with the top of other Phoenix pieces e.g. controllers and views):
defmodule PubrayWeb.SpaceLive do
use PubrayWeb, :live_controller
# ...
end
defmodule PubrayWeb.PubLive do
use PubrayWeb, :live_controller
# ...
end
# ...
This way, all live controllers that use the :live_controller
macro from PubrayWeb
will automatically:
- fetch space for space-specific routes (most of them) and redirect to the new link/domain if the link has changed/new domain was plugged
- fetch the logged in user, set up her locale and timezone preferences and fetch her personal space (needed in the layout)
- display a relevant flash asking user to reload & update the app in case when the LiveView’s built-in mehcanism detects a new release
Voila – a simple & readable hub in the code to see the chain of global web operations and the source of all the globally accessible socket assigns.
Of course, if need be, I could break this up into multiple macros for more specific cases, e.g. one for admin panel, one for space usage and one for static views like help pages. A piece of cake with Elixir’s excellent support for metaprogramming. 🚀
The when not mounted?(socket)
clause ensures that all of these will only happen when mounting (as we could also e.g. fetch current user for all event handlers to make sure that she didn’t just get banned etc). Same e.g. for checking for an update. A matter of choice.
Controller and action-specific hooks
Of course, I can do the same on the per-controller basis, taking full advantage of the when
clause. For instance:
defmodule PubrayWeb.SpaceLive do
use PubrayWeb, :live_controller
alias PubrayWeb.{LiveUserAuth, LiveSpaceHelpers}
plug {LiveUserAuth, :require_authenticated_user}
when action != :show
plug {LiveSpaceHelpers, :require_space_membership}
when action not in [:index, :show, :new] and
event not in [:create, :subscribe]
plug {LiveUserAuth, :require_publishing_allowance}
when action not in [:index, :show, :edit_team] and
event not in [:load_more_pubs, :subscribe]
plug {LiveSpaceHelpers, :require_space}
when not is_nil(action) and action not in [:index, :new]
# ...
end
This way, each live controller may decide if the user must be logged in, if she must be a member of the space, if she must have publishing priviliges (e.g. banned writers are out) and — since the global fetch_space
plug doesn’t do anything if there’s no space — we demand a redirect for such case but only for actions that do work on a specific space.
Just like with global plugs, the end result is compact and easy to understand. This time, the plugs are applied not just to mounting but also to event and message handlers so that there’s simply no code in entire live socket’s lifecycle that would be executed if e.g. the user just got banned — unless we explicitly make an exception. Also note that some plugs are applied only to specific actions — just like in regular Phoenix controllers.
Pro tip #1: Like Phoenix LiveController docs point out, when selectively applying authorization code it’s way safer to do whitelistig than blacklisting for the sake of future changes, i.e. it’s better to add another action or event that ends up broken due to getting falsely halted than to add one that allows unauthorized user to actually run it.
Pro tip #2: Or, better yet, we may treat the cases when conditional auth is required as a reason to break up a (live) controller into multiple ones, each focused on fulfilling the needs of just a single type or user (note for myself: consider adding this to the todo list).
Summary
Overall, I consider the experiment with Phoenix LiveController a successful one. It has contributed into creating a foundation for implementing around 20 well-performing and readable multi-action live controllers with a consistent syntax. Product goals are achieved, code base looks great and the library didn’t get in the way.
Of course, my experiences did translate into some items on the LiveController library todo list, but there’s nothing really serious in there. Here’s what I would tackle in this department:
- rename live controller “plugs” (e.g. to “live plugs”) to avoid confusion with the original Plug & Phoenix plugs with which live plugs may but usually aren’t compatible with
- halt plug chains explicitly (e.g. by calling
halt()
) instead of assuming that redirected and only redirected code is the one that halts the live chain (99% of the time this assumption is right, but still…) - emit telemetry events out of the box (adding them now is possible & fairly trivial on the app side but most libraries do that right on their side so why make the user do that?)
- make it easier to specify the view and template for rendering (right now, the
render
function must be defined to achieve that, ruining the default view & template mapping for other actions) - allow putting templates next to the live controllers (e.g.
pub_live.ex
+pub_live.index.html.leex
+pub_live.show.html.leex
) in a fashion similar to how LiveView does it these days
A nice bag of ideas for another day (or another contributor 😉).
That’s it for today! Be sure to comment on the above case that I’ve made for LiveController. Does it look fitting for the project like Pubray? Do you see yourself using it in your own? Or perhaps you see some fundamental flaws in the design? Also, please let me know if you’re interested in more info about this and similar topics, thanks!