# credo:disable-for-this-file Credo.Check.Refactor.IoPuts
defmodule Routex.Processing do
@moduledoc """
This module provides everything needed to process Phoenix routes. It executes
the `transform` callbacks from extensions to transform `Phoenix.Router.Route`
structs and `create_helpers` callbacks to create one unified Helper module.
**Powerful but thin** Although
Routext is able to influence the routes in Phoenix applications in profound
ways, the framework and it's extensions are a suprisingly lightweight piece
of compile-time middleware. This is made possible by the way router modules
are pre-processed by `Phoenix.Router` itself.
Prior to compilation of a router module, Phoenix Router registers all routes
defined in the router module using the attribute `@phoenix_routes`. Each
route is at that stage a `Phoenix.Router.Route` struct.
Any route enclosed in a `preprocess_using` block has received a `:private`
field in which Routex has put which Routext backend to use for that
particular route. By enumerating the routes, we can process each route using
the properties of this configuration and set struct values accordingly. This
processing is nothing more than (re)mapping the Route structs' values.
After the processing by Routex is finished, the `@phoenix_routes` attribute
in the router is erased and re-populated with the list of mapped
Phoenix.Router.Route structs.
Once the router module enters the compilation stage, Routex is already out of
the picture and route code generation is performed by Phoenix Router.
"""
alias Routex.Attrs
@type backend :: module
@type extension_module :: module
@type routes :: [Phoenix.Router.Route.t(), ...]
@doc """
Callback executed before compilation of a `Phoenix Router`. This callback is added
to the `@before_compile` callbacks by `Routex.Router`.
"""
@spec __before_compile__(Macro.Env.t()) :: :ok
def __before_compile__(env) do
IO.puts(["Start: Processing routes with ", inspect(__MODULE__)])
# Enable to get more descriptive error messages during development.
# Causes compilation failure when enabled.
wrap_in_task = System.get_env("ROUTEX_DEBUG") == "true"
if wrap_in_task do
IO.warn(
"\n\n!! Routex processing is wrapped in a task for debugging purposes. Compilation will fail !!\n\n"
)
task = Task.async(fn -> execute_callbacks(env) end)
Task.await(task, :infinity)
else
execute_callbacks(env)
end
end
@doc """
The main function of this module. Receives as only argument the environment of a
Phoenix router module.
"""
@spec execute_callbacks(Macro.Env.t()) :: :ok
def execute_callbacks(env) do
routes = env.module |> Module.get_attribute(:phoenix_routes) |> Enum.reverse()
# grouping per config module allows extensions to use accumulated values.
routes_per_cm = group_by_backend(routes)
# phase 1: transform route structs
processed_routes_per_cm_p1 =
for {backend, routes} <- routes_per_cm do
{backend, transform_routes(routes, backend, env)}
end
# phase 2: post transform route structs
processed_routes_per_cm_p2 =
for {backend, routes} <- processed_routes_per_cm_p1 do
{backend, post_transform_routes(routes, backend, env)}
end
# phase 3: generate ast for helpers
extensions_ast =
for {backend, routes} <- processed_routes_per_cm_p2, backend != nil do
create_helper_functions(routes, backend, env)
end
# restore routes order
new_routes =
processed_routes_per_cm_p2
|> Enum.map(&elem(&1, 1))
|> List.flatten()
|> Enum.sort_by(&Attrs.get(&1, :__order__))
new_routes
|> remove_build_info()
|> write_routes(env)
create_helper_module(extensions_ast, env)
IO.puts(["End: ", inspect(__MODULE__), " completed route processing.."])
:ok
end
defp group_by_backend(routes) do
routes
|> Enum.with_index()
|> Enum.map(&put_initial_attrs/1)
|> Enum.group_by(&Attrs.get(&1, :backend))
end
defp put_initial_attrs({{route, exprs}, index}),
do: {put_initial_attrs({route, index}), exprs}
defp put_initial_attrs({route, index}) do
meta =
Map.new()
|> Map.put(:__origin__, route.path)
|> Map.put(:__line__, route.line)
|> Map.put(:__order__, [0, index])
Attrs.merge(route, meta)
end
@spec helper_mod_name(Macro.Env.t()) :: module
@doc false
def helper_mod_name(env), do: Module.concat([env.module, :RoutexHelpers])
@spec transform_routes(routes, backend, Macro.Env.t()) :: routes
defp transform_routes(routes, nil, _env), do: routes
defp transform_routes(routes, backend, env) do
Code.ensure_loaded!(backend)
for extension <- backend.extensions(), extension != [], reduce: routes do
acc ->
exec_when_defined(backend, extension, :transform, acc, [acc, backend, env])
end
end
defp create_helper_functions(routes, backend, env) do
for extension <- backend.extensions(), extension != [] do
exec_when_defined(backend, extension, :create_helpers, nil, [
routes,
backend,
env
])
end
end
@spec post_transform_routes(routes, backend, Macro.Env.t()) :: routes
defp post_transform_routes(routes, nil, _env), do: routes
defp post_transform_routes(routes, backend, env) do
Code.ensure_loaded!(backend)
for extension <- backend.extensions(), extension != [], reduce: routes do
acc ->
exec_when_defined(backend, extension, :post_transform, acc, [acc, backend, env])
end
end
@spec create_helper_module(Macro.t(), Macro.Env.t()) ::
{:module, module, binary, term}
defp create_helper_module(extensions_ast, env) do
module = helper_mod_name(env)
IO.puts(["Create or update helper module ", inspect(module)])
# the on_mount callback relies on the availability of `attr/1`. As
# we need to know if it's available upfront, we check the extension AST
# to know if one of the extensions provided such helper.
has_attr_func =
extensions_ast
|> Macro.prewalker()
|> Enum.any?(&match?({:def, _meta1, [{:attrs, _meta2, _args} | _rest]}, &1))
prelude =
quote do
require Logger
unquote((has_attr_func && on_mount_ast(env)) || nil)
end
ast = [prelude | extensions_ast] |> List.flatten() |> Enum.uniq()
:ok = Macro.validate(ast)
Module.create(module, ast, env)
end
defp on_mount_ast(env) do
{:ok, phx_version} = :application.get_key(:phoenix, :vsn)
module = helper_mod_name(env)
assign_code =
if Version.match?(to_string(phx_version), "< 1.7.0-dev") do
quote do
opts = unquote(module).attrs(url)
{:cont,
Phoenix.LiveView.assign(
socket,
[url: url, __order__: opts.__order__] ++
Map.to_list(opts.assigns)
)}
end
else
quote do
opts = unquote(module).attrs(url)
{:cont,
Phoenix.Component.assign(
socket,
[url: url, __order__: opts.__order__] ++
Map.to_list(opts.assigns)
)}
end
end
quote do
def on_mount(_key, params, session, socket) do
socket =
Phoenix.LiveView.attach_hook(
socket,
:set_rtx,
:handle_params,
fn _params, url, socket ->
unquote(assign_code)
end
)
{:cont, socket}
end
end
end
defp remove_build_info(routes) do
Enum.map(routes, &Attrs.cleanup/1)
end
defp write_routes(routes, env) do
Module.delete_attribute(env.module, :phoenix_routes)
Module.register_attribute(env.module, :phoenix_routes, accumulate: true)
Enum.each(routes, &Module.put_attribute(env.module, :phoenix_routes, &1))
end
@doc """
Checks if the `callback` is defined. When defined it executes
the `callback` and returns the result , otherwise returns `default`.
"""
def exec_when_defined(backend, extension_module, callback, default, args) do
if callback_exists?(extension_module, callback, Enum.count(args)) do
postprint = [
inspect(backend),
" ⇒ ",
inspect(extension_module),
".",
callback |> Atom.to_string() |> String.trim_leading(":"),
"/",
to_string(Enum.count(args))
]
processing_print = "Executing: "
complete_print = "Completed: "
IO.write([processing_print, postprint])
result = apply(extension_module, callback, args)
IO.puts(["\r", complete_print, postprint])
result
else
default
end
end
defp callback_exists?(module, callback, arity) do
module.__info__(:functions)[callback] == arity
end
end