Packages

A Phoenix LiveView library for real-time API route management — discover, toggle, audit, and guard your routes from a dashboard

Current section

Files

Jump to
api_management_console lib api_management_console_v2 route_policies.ex
Raw

lib/api_management_console_v2/route_policies.ex

defmodule ApiManagementConsoleV2.RoutePolicies do
@moduledoc """
Route policy management for enabling/disabling endpoints.
Backed by DETS for persistence. Routes are keyed as `"method|path"`
(e.g. `"get|/api/users"`). Some routes are immutable (protected) and
cannot be toggled — configured via `:api_management_console, :protected_routes`.
Supports string prefixes/suffixes and regex patterns (matched against path and controller):
config :api_management_console,
protected_routes: ["/dev/dashboard", ~r{HealthController}]
"""
use GenServer
require Logger
import ApiManagementConsoleV2.Debug, only: [log: 1]
alias ApiManagementConsoleV2.RoutePolicies.Store
# --- Public API ---
@doc "Returns routes grouped by namespace, with enabled state and mutable flag."
def list_grouped_routes(router) do
stored = Map.new(Store.all())
router.__routes__()
|> Enum.map(&build_route_entry(&1, stored))
|> Enum.reject(&is_nil(&1))
|> Enum.sort_by(&{&1.group, &1.path, &1.method})
|> Enum.group_by(& &1.group)
end
@doc "Returns all routes as a flat list."
def list_all_routes(router) do
router
|> list_grouped_routes()
|> Map.values()
|> List.flatten()
end
@doc "Toggle a single route by key. Returns the old state."
def set_route_enabled(route_key, enabled) when is_binary(route_key) and is_boolean(enabled) do
log("[ApiPolicies] set_route_enabled — key=#{route_key}, enabled=#{enabled}")
Store.toggle(route_key, enabled)
end
@doc "Toggle all mutable routes in a group. Returns [{key, old_state}]."
def set_group_enabled(router, group, enabled) do
router
|> list_grouped_routes()
|> Map.get(group, [])
|> Enum.filter(& &1.mutable)
|> Enum.map(& &1.key)
|> Store.toggle_group(enabled)
end
@doc "Reset all policies — delete DETS file."
def reset_all, do: Store.reset_all()
@doc "Check if a route is enabled by key."
def enabled?(key), do: Store.enabled?(key)
@doc "Check if a request (method + path) is allowed."
def request_allowed?(router, method, path) do
routes = list_all_routes(router)
normalized = normalize_method(method)
result = case find_matching_route(routes, normalized, path) do
{:ok, %{mutable: true, key: key}} -> {:mutable, key, enabled?(key)}
{:ok, _immutable} -> {:immutable, nil, true}
:error -> {:not_found, nil, true}
end
log("[ApiPolicies] request_allowed? — #{method} #{path} -> #{inspect(result)}")
elem(result, 2)
end
@doc "Check if a path belongs to the API console itself."
def console_route?(path), do: String.contains?(path, "/api-console")
# --- GenServer ---
@doc false
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
ApiManagementConsoleV2.Accounts.ensure_admin_exists()
{:ok, nil}
end
# --- Private ---
defp build_route_entry(route, stored) do
if is_nil(route.verb) or not is_binary(route.path) do
nil
else
key = route_key(route.verb, route.path)
mutable = mutable_route?(route)
%{
key: key,
method: route.verb |> to_string() |> String.upcase(),
path: route.path,
controller: format_controller(route.plug),
action: format_action(route.plug_opts),
group: group_name(route),
mutable: mutable,
enabled: if(mutable, do: Map.get(stored, key, true), else: true),
plug: route.plug
}
end
end
defp route_key(verb, path) do
"#{normalize_method(verb)}|#{path}"
end
defp normalize_method(method) when is_atom(method),
do: method |> Atom.to_string() |> String.downcase()
defp normalize_method(method) when is_binary(method), do: String.downcase(method)
defp mutable_route?(route) do
# Console routes and sub-routes (login, logout, audit.csv) are always immutable
if console_route?(route.path) do
false
else
user_protected = Application.get_env(:api_management_console, :protected_routes, [])
immutable_prefixes = user_protected
is_immutable =
ApiManagementConsoleV2.ConsolePaths.matches?(route.path) or
Enum.any?(immutable_prefixes, fn prefix ->
cond do
is_binary(prefix) ->
String.starts_with?(route.path, prefix) or String.ends_with?(route.path, prefix)
is_struct(prefix, Regex) ->
Regex.match?(prefix, route.path) or Regex.match?(prefix, inspect(route.plug))
true -> false
end
end)
log("[ApiPolicies] mutable? — path=#{route.path}, immutable_prefixes=#{inspect(immutable_prefixes)}, is_immutable=#{is_immutable}")
not is_immutable
end
end
defp group_name(route) do
route.plug
|> Module.split()
|> List.last()
end
defp format_controller(plug) when is_atom(plug),
do: plug |> Module.split() |> List.last()
defp format_controller(_plug), do: "unknown"
defp format_action(plug_opts) when is_atom(plug_opts), do: plug_opts
defp format_action(plug_opts) when is_binary(plug_opts), do: plug_opts
defp format_action({key, _val}) when is_atom(key), do: key
defp format_action(_plug_opts), do: :unknown
defp find_matching_route(routes, method, path) do
Enum.find(routes, fn r ->
normalized = normalize_method(r.method) == method
normalized and route_path_match?(r.path, path)
end)
|> case do
nil -> :error
route -> {:ok, route}
end
end
defp route_path_match?(route_path, request_path) when route_path == request_path, do: true
defp route_path_match?(route_path, request_path) do
route_segments = String.split(route_path, "/", trim: true)
request_segments = String.split(request_path, "/", trim: true)
if length(route_segments) == length(request_segments) do
Enum.zip(route_segments, request_segments)
|> Enum.all?(fn {route_part, request_part} ->
String.starts_with?(route_part, ":") or route_part == request_part
end)
else
false
end
end
end