add dynamic routes plug and simplify router

Dynamic prefix rewriting for custom URL prefixes:
- DynamicRoutes plug rewrites /p/123 to /products/123
- 301 redirects from canonical to custom prefix
- Router simplified to 3 catch-all routes
- LiveSandboxHook for test process ownership

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-04-01 00:35:49 +01:00
parent 6b90b394dd
commit ecf84b81d1
4 changed files with 151 additions and 16 deletions

View File

@ -13,8 +13,8 @@ defmodule BerrypodWeb.Endpoint do
] ]
socket "/live", Phoenix.LiveView.Socket, socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]], websocket: [connect_info: [:user_agent, session: @session_options]],
longpoll: [connect_info: [session: @session_options]] longpoll: [connect_info: [:user_agent, session: @session_options]]
# In prod, image variants and mockups live on the persistent volume # In prod, image variants and mockups live on the persistent volume
# rather than inside the ephemeral release directory. # rather than inside the ephemeral release directory.
@ -50,6 +50,11 @@ defmodule BerrypodWeb.Endpoint do
plug Tidewave, allow_remote_access: true plug Tidewave, allow_remote_access: true
end end
# Enable sandbox for LiveView tests (allows LV processes to share test DB connection)
if Application.compile_env(:berrypod, :env) == :test do
plug Phoenix.Ecto.SQL.Sandbox
end
# Code reloading can be explicitly enabled under the # Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint. # :code_reloader configuration of your endpoint.
if code_reloading? do if code_reloading? do
@ -76,5 +81,6 @@ defmodule BerrypodWeb.Endpoint do
plug Plug.Head plug Plug.Head
plug Plug.Session, @session_options plug Plug.Session, @session_options
plug BerrypodWeb.Plugs.Redirects plug BerrypodWeb.Plugs.Redirects
plug BerrypodWeb.Plugs.DynamicRoutes
plug BerrypodWeb.Plugs.BrokenUrlTracker, router: BerrypodWeb.Router plug BerrypodWeb.Plugs.BrokenUrlTracker, router: BerrypodWeb.Router
end end

View File

@ -0,0 +1,24 @@
defmodule BerrypodWeb.LiveSandboxHook do
@moduledoc """
On-mount hook that allows LiveView processes to use the Ecto SQL sandbox.
Only active in test environment.
"""
import Phoenix.LiveView
import Phoenix.Component
def on_mount(:default, _params, _session, socket) do
socket =
assign_new(socket, :phoenix_ecto_sandbox, fn ->
if connected?(socket), do: get_connect_info(socket, :user_agent)
end)
metadata = socket.assigns.phoenix_ecto_sandbox
if metadata do
Phoenix.Ecto.SQL.Sandbox.allow(metadata, Ecto.Adapters.SQL.Sandbox)
end
{:cont, socket}
end
end

View File

@ -0,0 +1,101 @@
defmodule BerrypodWeb.Plugs.DynamicRoutes do
@moduledoc """
Plug that enables dynamic route prefixes for products, collections, and orders.
When a shop owner customises their URL prefixes (e.g., /products /p), this plug:
1. **Rewrites incoming paths** - If the URL uses a custom prefix (/p/123), rewrites
path_info to the canonical form (/products/123) so the router matches.
2. **Redirects old prefixes** - If the URL uses the old default prefix when a custom
one is set (/products/123 when prefix is "p"), issues a 301 redirect to /p/123.
This plug must run AFTER the Redirects plug (which handles trailing slashes and
explicit redirects) but BEFORE the router.
## How it works
The R module maintains an ETS cache of custom prefixes loaded from Settings.
`R.prefix/1` returns the current prefix for a type, and `R.prefix_type_from_segment/1`
performs reverse lookups.
## Example
# Settings: url_prefixes = %{"products" => "p", "collections" => "c"}
GET /p/123 Rewrite to /products/123, router matches :product
GET /products/123 Redirect 301 to /p/123
GET /c/art Rewrite to /collections/art, router matches :collection
GET /collections/art Redirect 301 to /c/art
When no custom prefix is set, paths pass through unchanged.
"""
import Plug.Conn
alias BerrypodWeb.R
@default_prefixes %{
products: "products",
collections: "collections",
orders: "orders"
}
def init(opts), do: opts
def call(conn, _opts) do
case conn.path_info do
[first_segment | rest] when rest != [] ->
handle_prefixed_path(conn, first_segment, rest)
_ ->
conn
end
end
defp handle_prefixed_path(conn, segment, rest) do
prefix_type = R.prefix_type_from_segment(segment)
current_prefix = prefix_type && R.prefix(prefix_type)
default_prefix = prefix_type && @default_prefixes[prefix_type]
cond do
# No prefix type matched - not a dynamic route
is_nil(prefix_type) ->
conn
# Using the current (possibly custom) prefix - rewrite to default for router
segment == current_prefix and current_prefix != default_prefix ->
rewrite_path(conn, default_prefix, rest, segment)
# Using default prefix but custom is set - redirect to custom prefix
segment == default_prefix and current_prefix != default_prefix ->
redirect_to_custom(conn, current_prefix, rest)
# Using the default prefix and no custom set, or already canonical
true ->
conn
end
end
# Rewrite path_info so the router's static routes match
# Store original path in conn.private for LiveView to use
defp rewrite_path(conn, canonical_prefix, rest, original_prefix) do
conn
|> put_private(:original_prefix, original_prefix)
|> put_private(:original_request_path, conn.request_path)
|> Map.put(:path_info, [canonical_prefix | rest])
end
# 301 redirect to the canonical custom prefix
defp redirect_to_custom(conn, custom_prefix, rest) do
path = "/" <> Enum.join([custom_prefix | rest], "/")
location = append_query(path, conn.query_string)
conn
|> put_resp_header("location", location)
|> send_resp(301, "")
|> halt()
end
defp append_query(path, ""), do: path
defp append_query(path, qs), do: "#{path}?#{qs}"
end

View File

@ -275,21 +275,25 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.PageEditorHook, :mount_page_editor}, {BerrypodWeb.PageEditorHook, :mount_page_editor},
{BerrypodWeb.NewsletterHook, :mount_newsletter} {BerrypodWeb.NewsletterHook, :mount_newsletter}
] do ] do
live "/", Shop.Page, :home # ┌─────────────────────────────────────────────────────────────────────┐
live "/about", Shop.Page, :about # │ Dynamic Shop Routes │
live "/delivery", Shop.Page, :delivery # │ │
live "/privacy", Shop.Page, :privacy # │ All shop pages are served by Shop.Page with runtime route resolution │
live "/terms", Shop.Page, :terms # │ via BerrypodWeb.R. This enables shop owners to customise URLs. │
live "/contact", Shop.Page, :contact # │ │
live "/collections/:slug", Shop.Page, :collection # │ Single-segment pages (/:slug): │
live "/products/:id", Shop.Page, :product # │ /cart, /about, /contact, /search, /delivery, /privacy, /terms, │
live "/cart", Shop.Page, :cart # │ /orders, /checkout/success, plus custom CMS pages │
live "/search", Shop.Page, :search # │ │
live "/checkout/success", Shop.Page, :checkout_success # │ Two-segment pages (/:prefix/:id_or_slug): │
live "/orders", Shop.Page, :orders # │ /products/:id, /collections/:slug, /orders/:number │
live "/orders/:order_number", Shop.Page, :order_detail # │ (prefixes are customisable, e.g. /p/:id, /shop/:slug) │
# │ │
# │ See: R.default_page_type/1, R.default_prefix_type/1, @page_modules │
# └─────────────────────────────────────────────────────────────────────┘
# Catch-all for custom CMS pages — must be last live "/", Shop.Page, :home
live "/:prefix/:id_or_slug", Shop.Page, :dynamic_prefix
live "/:slug", Shop.Page, :custom_page live "/:slug", Shop.Page, :custom_page
end end