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
|
# Theme CSS cache - must start after Endpoint for static_path/1 to work
|
||||||
Berrypod.Theme.CSSCache,
|
Berrypod.Theme.CSSCache,
|
||||||
# Page definition cache - loads page block lists into ETS
|
# 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
|
# 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