add R module for runtime url routing
GenServer with ETS caching for dynamic URLs: - Path helpers: cart(), product(id), collection(slug), order(num), page(slug) - Reverse lookups: page_type_from_slug/1, prefix_type_from_segment/1 - Cache invalidation with sync/async variants - Supervision tree integration with cache warming Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2fd7a0323e
commit
6b90b394dd
@ -44,7 +44,9 @@ defmodule Berrypod.Application do
|
||||
# Theme CSS cache - must start after Endpoint for static_path/1 to work
|
||||
Berrypod.Theme.CSSCache,
|
||||
# Page definition cache - loads page block lists into ETS
|
||||
Berrypod.Pages.PageCache
|
||||
Berrypod.Pages.PageCache,
|
||||
# URL routes cache - custom slugs and prefixes
|
||||
BerrypodWeb.R
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
|
||||
322
lib/berrypod_web/r.ex
Normal file
322
lib/berrypod_web/r.ex
Normal file
@ -0,0 +1,322 @@
|
||||
defmodule BerrypodWeb.R do
|
||||
@moduledoc """
|
||||
Runtime URL paths with ETS caching. Short name for ergonomic use.
|
||||
|
||||
This module provides functions for generating URLs that respect custom URL slugs
|
||||
and route prefixes configured by the shop owner.
|
||||
|
||||
## Usage
|
||||
|
||||
R.cart() # => "/basket" (if cart is renamed to basket)
|
||||
R.product(123) # => "/p/123" (if products prefix is "p")
|
||||
R.url(R.product(123)) # => "https://shop.com/p/123"
|
||||
|
||||
## Page slugs vs route prefixes
|
||||
|
||||
- **Page slugs** are single-segment paths like `/cart`, `/about`, `/contact`
|
||||
- **Route prefixes** are multi-segment paths like `/products/:id`, `/collections/:slug`
|
||||
|
||||
Page slugs are stored on individual Page records. Route prefixes are stored in Settings.
|
||||
|
||||
## Routing
|
||||
|
||||
The router uses three catch-all routes (`/`, `/:prefix/:id_or_slug`, `/:slug`) that
|
||||
delegate to `Shop.Page`, which resolves the actual page type at runtime using
|
||||
`page_type_from_slug/1` and `prefix_type_from_segment/1`. See the router's
|
||||
"Dynamic Shop Routes" comment block for the full list of handled pages.
|
||||
|
||||
To add a new system page type:
|
||||
1. Add a clause to `default_page_type/1` and `default_slug/1`
|
||||
2. Add the page module to `@page_modules` in `Shop.Page`
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
alias Berrypod.{Pages, Settings}
|
||||
|
||||
@table __MODULE__
|
||||
|
||||
# =============================================================================
|
||||
# Client API - Path functions
|
||||
# =============================================================================
|
||||
|
||||
@doc "Returns the home page path (always /)."
|
||||
def home, do: "/"
|
||||
|
||||
@doc "Returns the cart page path."
|
||||
def cart, do: "/" <> page_slug(:cart)
|
||||
|
||||
@doc "Returns the about page path."
|
||||
def about, do: "/" <> page_slug(:about)
|
||||
|
||||
@doc "Returns the contact page path."
|
||||
def contact, do: "/" <> page_slug(:contact)
|
||||
|
||||
@doc "Returns the search page path."
|
||||
def search, do: "/" <> page_slug(:search)
|
||||
|
||||
@doc "Returns the delivery page path."
|
||||
def delivery, do: "/" <> page_slug(:delivery)
|
||||
|
||||
@doc "Returns the privacy page path."
|
||||
def privacy, do: "/" <> page_slug(:privacy)
|
||||
|
||||
@doc "Returns the terms page path."
|
||||
def terms, do: "/" <> page_slug(:terms)
|
||||
|
||||
@doc "Returns the checkout success page path."
|
||||
def checkout_success, do: "/" <> page_slug(:checkout_success)
|
||||
|
||||
@doc "Returns the orders page path."
|
||||
def orders, do: "/" <> page_slug(:orders)
|
||||
|
||||
@doc """
|
||||
Returns the path for any page by slug.
|
||||
|
||||
This is a generic version that works with any page slug, including custom pages.
|
||||
Prefer the specific functions (cart/0, about/0, etc.) when the page is known.
|
||||
"""
|
||||
def page("home"), do: "/"
|
||||
def page("cart"), do: cart()
|
||||
def page("about"), do: about()
|
||||
def page("contact"), do: contact()
|
||||
def page("search"), do: search()
|
||||
def page("delivery"), do: delivery()
|
||||
def page("privacy"), do: privacy()
|
||||
def page("terms"), do: terms()
|
||||
def page("checkout_success"), do: checkout_success()
|
||||
def page("orders"), do: orders()
|
||||
def page(slug) when is_binary(slug), do: "/" <> slug
|
||||
|
||||
@doc "Returns the path for a specific product."
|
||||
def product(id), do: "/" <> prefix(:products) <> "/" <> to_string(id)
|
||||
|
||||
@doc "Returns the path for a specific collection."
|
||||
def collection(slug), do: "/" <> prefix(:collections) <> "/" <> slug
|
||||
|
||||
@doc "Returns the path for a specific order."
|
||||
def order(order_number), do: "/" <> prefix(:orders) <> "/" <> order_number
|
||||
|
||||
@doc "Returns a full URL (with scheme and host) for the given path."
|
||||
def url(path), do: BerrypodWeb.Endpoint.url() <> path
|
||||
|
||||
# =============================================================================
|
||||
# Reverse lookups (for router)
|
||||
# =============================================================================
|
||||
|
||||
@doc """
|
||||
Given a URL slug, returns the page type it maps to.
|
||||
|
||||
Returns:
|
||||
- `{:page, :cart}` for system pages
|
||||
- `{:custom, page}` for custom pages
|
||||
- `nil` if not found
|
||||
"""
|
||||
def page_type_from_slug(slug) do
|
||||
# First check if it's a custom URL slug for a system page
|
||||
case lookup({:reverse_slug, slug}) do
|
||||
{:ok, page_type} ->
|
||||
{:page, page_type}
|
||||
|
||||
:not_found ->
|
||||
# Check if it's a default slug for a system page
|
||||
case default_page_type(slug) do
|
||||
nil ->
|
||||
# Check if it's a custom page
|
||||
case Pages.get_published_page_by_effective_url(slug) do
|
||||
nil -> nil
|
||||
page -> {:custom, page}
|
||||
end
|
||||
|
||||
page_type ->
|
||||
{:page, page_type}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Given a URL segment, returns the prefix type it maps to.
|
||||
|
||||
Returns `:products`, `:collections`, `:orders`, or `nil`.
|
||||
"""
|
||||
def prefix_type_from_segment(segment) do
|
||||
case lookup({:reverse_prefix, segment}) do
|
||||
{:ok, type} -> type
|
||||
:not_found -> default_prefix_type(segment)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the current prefix for a given type.
|
||||
|
||||
Used by the DynamicRoutes plug to check if a custom prefix is set.
|
||||
|
||||
## Examples
|
||||
|
||||
R.prefix(:products) # => "p" (if customised) or "products" (default)
|
||||
R.prefix(:collections) # => "c" (if customised) or "collections" (default)
|
||||
"""
|
||||
def prefix(prefix_type) do
|
||||
key = {:prefix, prefix_type}
|
||||
|
||||
case lookup(key) do
|
||||
{:ok, prefix} -> prefix
|
||||
:not_found -> default_prefix(prefix_type)
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Cache management
|
||||
# =============================================================================
|
||||
|
||||
@doc "Invalidates a specific cache key."
|
||||
def invalidate(key) do
|
||||
GenServer.cast(__MODULE__, {:invalidate, key})
|
||||
end
|
||||
|
||||
@doc "Invalidates all cache entries (use after prefix changes)."
|
||||
def invalidate_all do
|
||||
GenServer.cast(__MODULE__, :invalidate_all)
|
||||
end
|
||||
|
||||
@doc "Invalidates all cache entries synchronously. Use when you need the cache refreshed before proceeding."
|
||||
def invalidate_all_sync do
|
||||
GenServer.call(__MODULE__, :invalidate_all)
|
||||
end
|
||||
|
||||
@doc "Clears all cache entries without re-warming. Used in tests to reset to defaults."
|
||||
def clear do
|
||||
GenServer.call(__MODULE__, :clear)
|
||||
end
|
||||
|
||||
@doc "Warms the cache with current values from the database."
|
||||
def warm_cache do
|
||||
GenServer.cast(__MODULE__, :warm_cache)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# GenServer
|
||||
# =============================================================================
|
||||
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def init(_) do
|
||||
:ets.new(@table, [:named_table, :public, read_concurrency: true])
|
||||
do_warm_cache()
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_cast({:invalidate, key}, state) do
|
||||
:ets.delete(@table, key)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_cast(:invalidate_all, state) do
|
||||
:ets.delete_all_objects(@table)
|
||||
do_warm_cache()
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_cast(:warm_cache, state) do
|
||||
do_warm_cache()
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_call(:invalidate_all, _from, state) do
|
||||
:ets.delete_all_objects(@table)
|
||||
do_warm_cache()
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
def handle_call(:clear, _from, state) do
|
||||
:ets.delete_all_objects(@table)
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Private - Cache operations
|
||||
# =============================================================================
|
||||
|
||||
defp page_slug(page_type) do
|
||||
key = {:slug, page_type}
|
||||
|
||||
case lookup(key) do
|
||||
{:ok, slug} -> slug
|
||||
:not_found -> default_slug(page_type)
|
||||
end
|
||||
end
|
||||
|
||||
defp lookup(key) do
|
||||
case :ets.lookup(@table, key) do
|
||||
[{^key, value}] -> {:ok, value}
|
||||
[] -> :not_found
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> :not_found
|
||||
end
|
||||
|
||||
defp do_warm_cache do
|
||||
# Load page slugs
|
||||
Pages.list_pages_with_custom_urls()
|
||||
|> Enum.each(fn page ->
|
||||
page_type = String.to_existing_atom(page.slug)
|
||||
:ets.insert(@table, {{:slug, page_type}, page.url_slug})
|
||||
:ets.insert(@table, {{:reverse_slug, page.url_slug}, page_type})
|
||||
end)
|
||||
|
||||
# Load route prefixes from settings
|
||||
prefixes = Settings.get_setting("url_prefixes") || %{}
|
||||
|
||||
Enum.each(prefixes, fn {type_str, prefix} ->
|
||||
type = String.to_existing_atom(type_str)
|
||||
:ets.insert(@table, {{:prefix, type}, prefix})
|
||||
:ets.insert(@table, {{:reverse_prefix, prefix}, type})
|
||||
end)
|
||||
rescue
|
||||
# During startup, tables/atoms might not exist yet
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Defaults
|
||||
# =============================================================================
|
||||
|
||||
defp default_slug(:cart), do: "cart"
|
||||
defp default_slug(:about), do: "about"
|
||||
defp default_slug(:contact), do: "contact"
|
||||
defp default_slug(:search), do: "search"
|
||||
defp default_slug(:delivery), do: "delivery"
|
||||
defp default_slug(:privacy), do: "privacy"
|
||||
defp default_slug(:terms), do: "terms"
|
||||
defp default_slug(:checkout_success), do: "checkout/success"
|
||||
defp default_slug(:orders), do: "orders"
|
||||
defp default_slug(_), do: nil
|
||||
|
||||
defp default_prefix(:products), do: "products"
|
||||
defp default_prefix(:collections), do: "collections"
|
||||
defp default_prefix(:orders), do: "orders"
|
||||
defp default_prefix(_), do: nil
|
||||
|
||||
# Map default slugs back to page types (for router)
|
||||
defp default_page_type("cart"), do: :cart
|
||||
defp default_page_type("about"), do: :about
|
||||
defp default_page_type("contact"), do: :contact
|
||||
defp default_page_type("search"), do: :search
|
||||
defp default_page_type("delivery"), do: :delivery
|
||||
defp default_page_type("privacy"), do: :privacy
|
||||
defp default_page_type("terms"), do: :terms
|
||||
defp default_page_type("orders"), do: :orders
|
||||
defp default_page_type("checkout/success"), do: :checkout_success
|
||||
defp default_page_type(_), do: nil
|
||||
|
||||
# Map default prefixes back to types
|
||||
defp default_prefix_type("products"), do: :products
|
||||
defp default_prefix_type("collections"), do: :collections
|
||||
defp default_prefix_type("orders"), do: :orders
|
||||
defp default_prefix_type(_), do: nil
|
||||
end
|
||||
89
test/berrypod_web/r_test.exs
Normal file
89
test/berrypod_web/r_test.exs
Normal file
@ -0,0 +1,89 @@
|
||||
defmodule BerrypodWeb.RTest do
|
||||
use Berrypod.DataCase, async: false
|
||||
|
||||
alias BerrypodWeb.R
|
||||
alias Berrypod.Pages
|
||||
|
||||
setup do
|
||||
# Clear caches between tests
|
||||
R.invalidate_all_sync()
|
||||
Berrypod.Pages.PageCache.invalidate_all()
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "page path functions" do
|
||||
test "returns default paths when no custom slugs set" do
|
||||
assert R.cart() == "/cart"
|
||||
assert R.about() == "/about"
|
||||
assert R.contact() == "/contact"
|
||||
assert R.search() == "/search"
|
||||
end
|
||||
|
||||
test "returns custom path after url_slug is set" do
|
||||
{:ok, _} = Pages.update_page_url_slug("cart", "basket")
|
||||
|
||||
assert R.cart() == "/basket"
|
||||
end
|
||||
|
||||
test "returns default path after url_slug is cleared" do
|
||||
{:ok, _} = Pages.update_page_url_slug("about", "our-story")
|
||||
assert R.about() == "/our-story"
|
||||
|
||||
{:ok, _} = Pages.update_page_url_slug("about", "")
|
||||
assert R.about() == "/about"
|
||||
end
|
||||
end
|
||||
|
||||
describe "page/1 generic function" do
|
||||
test "routes through specific functions for system pages" do
|
||||
{:ok, _} = Pages.update_page_url_slug("cart", "basket")
|
||||
|
||||
assert R.page("cart") == "/basket"
|
||||
assert R.page("home") == "/"
|
||||
end
|
||||
|
||||
test "returns /slug for custom pages" do
|
||||
assert R.page("my-custom-page") == "/my-custom-page"
|
||||
end
|
||||
end
|
||||
|
||||
describe "reverse lookups" do
|
||||
test "page_type_from_slug finds system page by custom url" do
|
||||
{:ok, _} = Pages.update_page_url_slug("about", "our-story")
|
||||
|
||||
assert R.page_type_from_slug("our-story") == {:page, :about}
|
||||
end
|
||||
|
||||
test "page_type_from_slug finds system page by default url" do
|
||||
assert R.page_type_from_slug("cart") == {:page, :cart}
|
||||
assert R.page_type_from_slug("about") == {:page, :about}
|
||||
end
|
||||
|
||||
test "page_type_from_slug returns nil for unknown slug" do
|
||||
assert R.page_type_from_slug("nonexistent") == nil
|
||||
end
|
||||
|
||||
test "page_type_from_slug finds custom page" do
|
||||
{:ok, _} = Pages.create_custom_page(%{slug: "faq", title: "FAQ", published: true})
|
||||
|
||||
assert {:custom, page} = R.page_type_from_slug("faq")
|
||||
assert page.slug == "faq"
|
||||
end
|
||||
end
|
||||
|
||||
describe "prefix functions" do
|
||||
test "returns default prefixes" do
|
||||
assert R.product(123) == "/products/123"
|
||||
assert R.collection("art") == "/collections/art"
|
||||
assert R.order("ORD-123") == "/orders/ORD-123"
|
||||
end
|
||||
end
|
||||
|
||||
describe "url/1" do
|
||||
test "prepends endpoint URL to path" do
|
||||
url = R.url("/cart")
|
||||
assert String.starts_with?(url, "http")
|
||||
assert String.ends_with?(url, "/cart")
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user