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 Routex is able to influence the routes in Phoenix applications in profound ways, the framework and its extensions are a surprisingly 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 Routex 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 alias Routex.Types, as: T alias Routex.Utils @type extension_module :: module() @type helper_module :: module() @doc """ Callback executed before compilation of a `Phoenix Router`. This callback is added to the `@before_compile` callbacks by `Routex.Router`. """ @spec __before_compile__(T.env()) :: :ok def __before_compile__(env) do Utils.print(__MODULE__, ["Start processing routes"]) execute_callbacks(env) end @doc false @spec helper_mod_name(module()) :: module def helper_mod_name(module), do: Module.concat([module, :RoutexHelpers]) @doc """ The main function of this module. Receives as only argument the environment of a Phoenix router module. """ @spec execute_callbacks(T.env()) :: :ok def execute_callbacks(env) do # routes are returned in reversed order of definition routes = env.module |> Utils.get_attribute(:phoenix_routes) |> Enum.reverse() execute_callbacks(env, routes) end @spec execute_callbacks(T.env(), T.routes()) :: :ok def execute_callbacks(env, routes) when is_list(routes) do helper_mod_name = helper_mod_name(env.module) grouped_routes = routes |> put_initial_attrs(helper_mod_name) |> group_by_backend() {non_processed_routes, backend_routes} = Map.pop(grouped_routes, nil, []) backend_routes_callbacks = add_callbacks_map(backend_routes) {ast_per_extension, transformed_routes_per_backend} = execute(backend_routes_callbacks, env) ast_per_extension |> generate_helper_ast(helper_mod_name) |> create_helper_module(helper_mod_name, env) processed_routes = flatten_transformed_routes(transformed_routes_per_backend) (non_processed_routes ++ processed_routes) |> restore_routes_order() |> print_summary() |> remove_build_info() |> write_routes(env) Utils.print(__MODULE__, ["End: ", inspect(__MODULE__), " completed route processing."]) :ok end defp debug?, do: System.get_env("ROUTEX_DEBUG") == "true" defp print_summary(routes) do original_amount = Enum.count(routes, fn route -> Routex.Attrs.get(route, :__origin__) == route.path end) current_amount = Enum.count(routes) generated_amount = current_amount - original_amount Routex.Utils.print(__MODULE__, [ "Routes >> Original: ", to_string(original_amount), " | Generated: ", to_string(generated_amount), " | Total: ", to_string(current_amount) ]) routes end defp put_initial_attrs(routes, helper_mod_name) do routes |> Enum.with_index() |> Enum.map(fn {route, index} -> rtx = %{ __origin__: route.path, __branch__: [index], __helper_mod__: helper_mod_name } overrides = Map.get(route.private, :rtx, %{}) attrs = Map.merge(rtx, overrides) Attrs.merge(route, attrs) end) end defp group_by_backend(routes) do routes |> Enum.group_by(&Attrs.get(&1, :__backend__)) end def add_callbacks_map(routes_per_backend) do Enum.map(routes_per_backend, fn {backend, routes} -> {backend, backend.callbacks(), routes} end) end defp execute(backend_routes_callbacks, env) do transformed_routes_per_backend = transform_routes_per_backend(backend_routes_callbacks, env) helpers_ast = generate_helpers_ast(:create_helpers, transformed_routes_per_backend, env) shared_helpers_ast = generate_helpers_ast(:create_shared_helpers, transformed_routes_per_backend, env) {helpers_ast ++ shared_helpers_ast, transformed_routes_per_backend} end # Transformations def transform_routes_per_backend(backend_routes_callbacks, env) do Enum.map(backend_routes_callbacks, fn {backend, callbacks, routes} -> transformed_routes = apply_transform_callbacks(callbacks, routes, backend, env) {backend, callbacks, transformed_routes} end) end defp apply_transform_callbacks(callbacks, routes, backend, env) do Enum.reduce([:transform, :post_transform], routes, fn callback_name, acc -> extensions = callbacks[callback_name] apply_transform_callback(callback_name, extensions, acc, backend, env) end) end defp apply_transform_callback(callback_name, extensions, routes, backend, env) do Enum.reduce(extensions, routes, fn extension, inner_routes -> execute_callback(callback_name, backend, extension, [inner_routes, backend, env]) end) end # AST Generation defp generate_helpers_ast( :create_helpers = callback_name, transformed_routes_per_backend, env ) do Enum.flat_map(transformed_routes_per_backend, fn {backend, callbacks, routes} -> extensions = callbacks[callback_name] generate_helper_ast(callback_name, extensions, routes, backend, env) end) end defp generate_helpers_ast( :create_shared_helpers = callback_name, transformed_routes_per_backend, env ) do routes_per_extension = transformed_routes_per_backend |> Enum.flat_map(fn {_backend, callbacks, routes} -> Enum.map(callbacks[callback_name], fn ext -> {ext, routes} end) end) |> Enum.group_by( fn {ext, _routes} -> ext end, fn {_ext, routes} -> routes end ) Enum.flat_map(routes_per_extension, fn {ext, routes} -> routes = List.flatten(routes) backends = Routex.Route.get_backends(routes) extensions = [ext] generate_helper_ast(callback_name, extensions, routes, backends, env) end) end defp generate_helper_ast(callback_name, extensions, routes, backend, env) do Enum.map(extensions, fn extension -> ast = callback_name |> execute_callback(backend, extension, [routes, backend, env]) |> dedup_ast() :ok = Macro.validate(ast) {extension, ast} end) end defp restore_routes_order(routes), do: Enum.sort_by(routes, &Attrs.get(&1, :__branch__)) defp flatten_transformed_routes(transformed_routes_per_backend), do: Enum.flat_map(transformed_routes_per_backend, fn {_backend, _callbacks, routes} -> routes end) @spec create_helper_module(T.ast(), helper_module, T.env()) :: {:module, module, binary, term} defp create_helper_module(ast, module, env) do Utils.print(__MODULE__, ["Create or update helper module ", inspect(module)]) Module.create(module, ast, env) end defp generate_helper_ast(ast_per_extension, module) do prelude = quote do require Logger use Routex.HelperFallbacks end helpers_ast = ast_per_extension |> Enum.map(fn {_ext, ast} -> ast end) |> dedup_ast() ast = [prelude, helpers_ast] if Application.fetch_env(:routex, :helper_mod_dir) != :error do sub_path = (module |> to_string() |> String.trim_leading("Elixir.")) <> ".ex" write_ast(ast, module, sub_path) end :ok = Macro.validate(ast) ast end defp write_ast(ast, module, sub_path) do dir = Application.fetch_env!(:routex, :helper_mod_dir) path = Path.join(dir, sub_path) Routex.Utils.print(__MODULE__, "Wrote AST of #{module} to #{path}") wrapped_ast = quote do defmodule unquote(module) do @moduledoc """ This code is generated by Routex and is for inspection purpose only """ unquote_splicing(ast) end end formatted_binary = wrapped_ast |> Macro.to_string() |> Code.format_string!() :ok = File.write(path, formatted_binary) end # prevent duplication of attributes, functions etc defp dedup_ast(ast) do ast |> List.wrap() |> List.flatten() |> Enum.reject(&is_nil/1) |> Enum.uniq() 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 """ Executes the specified callback for an extension and returns the result. """ def execute_callback(callback, backend, extension_module, 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: " debug?() && IO.write([processing_print, postprint]) result = apply(extension_module, callback, args) debug?() && IO.write(["\r", complete_print, postprint, "\n"]) result end end