DynamicRoutes (DynamicRoutes v0.1.0)

Copy Markdown View Source

Database-driven top-level URLs in Phoenix, without your own routes getting shadowed.

Phoenix routes are compile-time macros, so a URL that lives in a database row cannot be declared. The usual workaround is a catch-all at /, which then swallows every route declared after it — Phoenix matches in declaration order and there is no fall-through. The standard advice is to put your specific routes first and be careful. This makes the ordering stop mattering.

defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  use DynamicRoutes, resolver: MyApp.Pages

  scope "/", MyAppWeb do
    pipe_through :browser

    dynamic_routes MyAppWeb.PageController, :show
    get "/settings", SettingsController, :edit
  end
end

/settings still reaches SettingsController even though the catch-all is written above it, and /about reaches PageController if MyApp.Pages says it should.

How it works

A declared route always wins. On each request the router is asked — through Phoenix.Router.route_info/4, its own public lookup — whether anything matches. If something does, the request is passed straight through and Phoenix behaves exactly as it always would.

Only when nothing matches, on a request that was going to be a 404 anyway, is the resolver consulted. If it claims the path, conn.path_info is rewritten to an internal route, super/2 runs the real Phoenix pipeline — plugs, telemetry, error handling, all of it — and the path is put back before the controller sees it.

Nothing here reimplements routing. The router does the matching, twice, and the second time it is matching a route that was declared normally.

Setting it up

Three pieces.

A resolver — see DynamicRoutes.Resolver:

defmodule MyApp.Pages do
  @behaviour DynamicRoutes.Resolver

  @impl true
  def resolve([slug]) do
    case MyApp.Content.page_by_slug(slug) do
      nil -> :pass
      page -> {:match, page}
    end
  end

  def resolve(_path), do: :pass
end

use DynamicRoutes in the router, after use MyAppWeb, :router — it overrides the call/2 that defines, which has to exist first.

dynamic_routes/2 inside the scope and pipeline the dynamic pages should run through.

Then the controller:

def show(conn, _params) do
  page = DynamicRoutes.resolution(conn)
  render(conn, :show, page: page)
end

The resolver already loaded it, so there is nothing to look up again.

Cache invalidation

Resolver answers are cached, including the misses — a public site is asked for paths that do not exist far more often than for ones that do. Call invalidate/0 when pages change:

def publish(page) do
  {:ok, page} = Repo.update(changeset)
  DynamicRoutes.invalidate()
  {:ok, page}
end

In a multi-node deployment that clears the local node only; broadcast it if you run more than one. See DynamicRoutes.Cache for the settings, including turning the cache off in development so edits show up without a restart.

What this does not do

Dynamic paths do not appear in ~p sigils, route helpers, or mix phx.routes — they are data, and the compiler has never seen them. Build those URLs from the same records the resolver reads. (The internal route does appear in all three, labelled with the controller it dispatches to. Requesting it directly is a 404.)

The target must be a controller, not a LiveView. live/3 compiles to a route whose private carries the live-session metadata that Phoenix.LiveView.Plug matches on, and there is no way to reconstruct that from here — a LiveView target raises. If your dynamic pages are LiveViews, this is not the tool.

Precedence is per method and path, not per path. A declared post "/x" does not stop a resolver claiming GET /x, where plain Phoenix would 404.

Dynamic pages answer every method, because the internal route is declared with match :*. Under plain Phoenix a get-only page would 404 a POST; here it reaches the controller. If that matters, check conn.method there.

A forward/2 prefix cannot host dynamic paths. route_info/4 matches the forward itself, so everything beneath it counts as declared and the forwarded plug's own 404 wins.

Summary

Functions

Installs dynamic routing into a Phoenix router.

How many resolver answers are currently cached.

Whether this request was routed dynamically.

Declares the route that dynamic paths are dispatched to.

Drops every cached resolver answer.

Drops the cached answer for one path.

What the resolver returned for this request.

Functions

__using__(opts)

(macro)

Installs dynamic routing into a Phoenix router.

Must come after use MyAppWeb, :router (or use Phoenix.Router), because it overrides the call/2 those define.

Options

cache_size()

@spec cache_size() :: non_neg_integer()

How many resolver answers are currently cached.

dynamic?(conn)

@spec dynamic?(Plug.Conn.t()) :: boolean()

Whether this request was routed dynamically.

dynamic_routes(controller, action, opts \\ [])

(macro)

Declares the route that dynamic paths are dispatched to.

Place it inside the scope and pipeline the dynamic pages should run through. Position within the router does not matter — declared routes are matched first regardless — so put it wherever it reads best.

scope "/", MyAppWeb do
  pipe_through :browser

  dynamic_routes MyAppWeb.PageController, :show
end

The controller module must be fully qualified, even inside a scope with an alias: it travels as route metadata rather than as the route's plug, and Phoenix only expands aliases for the latter.

Options

  • :path — the internal path segment, default "__dynamic__". Change it only if that genuinely collides with a real URL of yours.
  • :as — the route helper name, default :dynamic_routes.

invalidate()

@spec invalidate() :: :ok

Drops every cached resolver answer.

Call it whenever the set of dynamic paths changes. Clears the local node only.

invalidate(path)

@spec invalidate([String.t()] | String.t()) :: :ok

Drops the cached answer for one path.

DynamicRoutes.invalidate(["blog", "my-post"])
DynamicRoutes.invalidate("/blog/my-post")

resolution(conn)

@spec resolution(Plug.Conn.t()) :: term() | nil

What the resolver returned for this request.

nil when the request reached the controller through a declared route, so a controller serving both can tell the difference.