defmodule Topical.Registry do @moduledoc false def start_link(options) do name = Keyword.fetch!(options, :name) routes = options |> Keyword.fetch!(:topics) |> build_routes() {registry_name, supervisor_name} = resolve_names(name) Supervisor.start_link( [ {Registry, name: registry_name, keys: :unique, meta: [routes: routes]}, {DynamicSupervisor, name: supervisor_name, strategy: :one_for_one} ], strategy: :one_for_all, name: name ) end defp parse_route(route) do if is_binary(route) do route |> String.split("/") |> Enum.map(fn ":" <> atom -> String.to_atom(atom) part -> URI.decode(part) end) else route end end defp build_routes(modules) do Enum.map(modules, &{parse_route(&1.route()), &1}) end defp resolve_route(route, routes) do parts = if is_binary(route) do route |> String.split("/") |> Enum.map(&URI.decode/1) else route end Enum.find_value(routes, fn {route, module} -> match = match_route(parts, route) if match do {module, match} end end) end defp match_route(parts, route) do if length(parts) == length(route) do parts |> Enum.zip(route) |> Enum.reduce_while([], fn {part, route_part}, params -> cond do is_atom(route_part) -> {:cont, Keyword.put(params, route_part, part)} part == route_part -> {:cont, params} true -> {:halt, nil} end end) else nil end end @doc """ Resolves a route and normalizes request params. Returns `{:ok, module, all_params, topic_key}` or `{:error, reason}`. The returned values can be passed to `get_topic/4` to avoid resolving twice. """ def resolve_topic(name, route, request_params \\ %{}) do {registry_name, _supervisor_name} = resolve_names(name) {:ok, routes} = Registry.meta(registry_name, :routes) case resolve_route(route, routes) do {module, route_params} -> case normalize_params(request_params, module.params()) do {:ok, normalized_params} -> topic_key = {route, normalized_params} all_params = Keyword.merge(route_params, normalized_params) {:ok, module, all_params, topic_key} {:error, reason} -> {:error, reason} end nil -> {:error, :not_found} end end @doc """ Gets or starts a topic, after checking authorization. Accepts the resolved values from `resolve_topic/3`. Returns `{:ok, pid}` if authorized and the topic is running (or was started), or `{:error, reason}` if authorization fails or the topic cannot be started. """ def get_topic(name, module, all_params, topic_key, context) do {registry_name, supervisor_name} = resolve_names(name) case module.authorize(all_params, context) do :ok -> case Registry.lookup(registry_name, topic_key) do [{pid, _}] -> {:ok, pid} [] -> spec = {Topical.Topic.Server, name: {:via, Registry, {registry_name, topic_key}}, id: topic_key, module: module, init_arg: all_params} case DynamicSupervisor.start_child(supervisor_name, spec) do {:ok, pid} -> {:ok, pid} {:error, {:already_started, pid}} -> {:ok, pid} {:error, reason} -> {:error, reason} end end {:error, reason} -> {:error, reason} end end # Normalizes request params against declared params: # - Filters to only declared param names (ignores unknown params) # - Converts string keys to atoms (safe because we only use declared names) # - Normalizes empty strings to nil # - Applies default values for missing params # - Returns {:ok, sorted_keyword_list} or {:error, {:invalid_param, name}} defp normalize_params(request_params, declared_params) do declared_params |> Enum.reduce_while({:ok, []}, fn {name, default}, {:ok, acc} -> # Try both atom and string key value = case Map.get(request_params, name) do nil -> Map.get(request_params, Atom.to_string(name)) v -> v end case value do nil -> {:cont, {:ok, [{name, default} | acc]}} "" -> {:cont, {:ok, [{name, default} | acc]}} v when is_binary(v) -> {:cont, {:ok, [{name, v} | acc]}} _ -> {:halt, {:error, {:invalid_param, name}}} end end) |> case do {:ok, params} -> {:ok, Enum.sort_by(params, fn {name, _} -> name end)} {:error, reason} -> {:error, reason} end end defp resolve_names(name) when is_atom(name) do registry_name = Module.concat(name, "Registry") supervisor_name = Module.concat(name, "Supervisor") {registry_name, supervisor_name} end end