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>
323 lines
9.9 KiB
Elixir
323 lines
9.9 KiB
Elixir
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
|