Building a mountable library

Copy Markdown View Source

A mountable library is a package a Phoenix app mounts with one line in its router. This guide builds one from the definition module up.

Vocabulary

TermMeaning
host appthe Phoenix app that mounts the library
mountable librarya library built with PhoenixMountable
definition modulethe library module that calls use PhoenixMountable
mount macrothe router macro generated for hosts, such as my_lib/2
mount paththe path given to the mount macro, such as "/my_lib"
mountone mounted instance: a path and its options

The definition module

defmodule MyLib.Router do
  use PhoenixMountable,
    otp_app: :my_lib,
    macro: :my_lib,
    layout: {MyLib.Layouts, :root}

  options resolver: [type: :atom, default: MyLib.Resolver, verify: {:behaviour, MyLib.Resolver}]

  routes do
    live "/", MyLib.HomeLive, :index
  end
end

Naming it MyLib.Router lets hosts write import MyLib.Router and my_lib "/my_lib", like import Phoenix.LiveDashboard.Router and live_dashboard "/dashboard".

The use options are documented in PhoenixMountable. The ones that shape the library:

  • :layout: {module, function} for a document of the library's own (its own CSS and JavaScript, like LiveDashboard), or :host to render inside the host's root layout (the host's Tailwind and JavaScript). {PhoenixMountable.Layout, :root} is a ready-made document.
  • :assets: how the library's files reach the browser. See the Assets guide.
  • :on_mount: hooks run in every live session of the library, after the host's hooks.
  • :schema: the library's migrations, checked on first use. See the Migrations guide.

Options

options/1 takes a NimbleOptions schema. A host's options are validated when its router compiles, and the schema documents the mount macro (h MyLib.Router.my_lib).

options resolver: [
          type: :atom,
          default: MyLib.Resolver,
          verify: {:behaviour, MyLib.Resolver},
          doc: "Resolves the current user."
        ],
        theme: [type: {:in, [:light, :dark]}],
        themes: [type: {:list, :atom}, default: [:light, :dark]]

:verify checks a module option after the host router compiles: it exists (:module), and implements a behaviour ({:behaviour, mod}). Checking it while the router compiles would deadlock when the module uses the host's ~p.

normalize_options/1 derives and cross-checks options:

@impl PhoenixMountable
def normalize_options(opts) do
  opts = Keyword.put_new(opts, :theme, hd(opts[:themes]))

  if opts[:theme] not in opts[:themes] do
    raise ArgumentError, "theme #{inspect(opts[:theme])} is not one of #{inspect(opts[:themes])}"
  end

  opts
end

Every mount macro also takes the built-in options: :as, :on_mount, :socket_path, :transport, :csp_nonce_assign_key, :asset_host and :pubsub.

Routes

routes/1 uses the macros of PhoenixMountable.Routes, which mirror Phoenix's, with paths relative to the mount:

pipeline :api do
  plug :accepts, ["json"]
end

routes do
  get "/export", MyLib.ExportController, :csv, as: :export
  forward "/webhooks", MyLib.Webhooks

  scope "/api" do
    pipe_through :api
    get "/items", MyLib.API.ItemController, :index
  end

  live_session :public, host_on_mount: false do
    live "/share/:token", MyLib.ShareLive, :show
  end

  live_session :default, on_mount: [MyLib.Auth] do
    live "/", MyLib.HomeLive, :index
    live "/items/:id", MyLib.ItemLive, :show
  end
end

The mount macro expands them into the host router, inside the host's scope and pipelines. The routes are named after the mount: the host's live sessions are :my_lib and :my_lib_public, and route helpers my_lib_path and my_lib_export_path. A host mounting the library twice passes as: to one of the mounts.

host_on_mount: false keeps the host's hooks out of a session, for pages that must stay public when the host's hooks require a signed-in user.

The mount

In LiveViews and their templates, the mount is @mount; in controllers and plugs of the library's routes, conn.assigns.mount; anywhere in the process, PhoenixMountable.Mount.current/0.

def mount(_params, _session, socket) do
  resolver = socket.assigns.mount.options.resolver
  ...
end

The session callback runs on the HTTP request, with the host's conn, for values that need it, such as the current user:

@impl PhoenixMountable
def session(conn, mount, _session_name) do
  %{"user" => mount.options.resolver.resolve_user(conn)}
end

Paths

~m builds paths relative to the mount, and warns at compile time when a path matches none of the library's routes:

use PhoenixMountable.Path, mountable: MyLib.Router

push_navigate(socket, to: ~m"/items/#{item}")
~H|<.link navigate={~m"/items?#{[page: 2]}"}>Next</.link>|

The mount comes from the request, so the same code gives the right paths when the host mounts the library twice (:as).

A library with several definitions (an admin and a public page, say) links from one to another by the other mount's name, wherever the host put it:

PhoenixMountable.Path.path(conn, :my_lib_page, "/")
#=> "/changelog"

# nil when the host has not mounted the page: show the link only then
if PhoenixMountable.Mount.sibling(socket, :my_lib_page), do: ...

The name is the mount macro, or the host's :as; a library that lets the host rename a sibling takes the name as an option.

Without LiveView

A library without live routes needs only Phoenix. Its controllers render in the library's root layout (or the host's, with layout: :host), and PhoenixMountable.HTML renders the meta and asset tags in EEx templates. A JSON API is a forward to a Plug.Router or Phoenix.Router of the library's, and a single page app a controller that renders its shell with the PhoenixMountable.Assets.Static strategy.

A core and a web package

A library whose functionality is useful without its pages (Oban and Oban Web, Lotus and Lotus Web) splits in two packages. Both can depend on PhoenixMountable: the core uses PhoenixMountable.Migrations and the processes, which need no Phoenix, and the web package the definition module. The web package's :schema option names the core's migrations module.