Skip to content

Instantly share code, notes, and snippets.

@jiegillet
Last active April 21, 2024 20:42
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jiegillet/e6357c82e36a848ad59295eb3d5a1135 to your computer and use it in GitHub Desktop.
Save jiegillet/e6357c82e36a848ad59295eb3d5a1135 to your computer and use it in GitHub Desktop.
defmodule Mix.Tasks.ConvertToVerifiedRoutes do
@moduledoc """
Replaces routes with verified routes.
Forked from
https://gist.github.com/andreaseriksson/e454b9244a734310d4ab74d8595f98cd
This requires all routes to consistently be aliased with
alias MyAppWeb.Router.Helpers, as: Routes
Run with
mix convert_to_verified_routes
If you have routes only available in certain environments like tests, prepend MIX_ENV=test.
You might need to add `use MyAppWeb.VerifiedRoutes` in some places, and there
will most likely be some edge cases that will not get resolved properly.
See instructions at the bottom to format routes in your .heex files.
"""
use Mix.Task
@web_module MyAppWeb
def run(_) do
Path.wildcard("test/**/*.ex*")
|> Enum.concat(Path.wildcard("lib/**/*.ex*"))
|> Enum.sort()
|> Enum.filter(&(&1 |> File.read!() |> String.contains?("Routes.")))
|> Enum.each(&format_file/1)
:ok
end
def format_file(filename) do
Mix.shell().info(filename)
formatted_content =
filename
|> File.read!()
|> format_string()
File.write!(filename, [formatted_content, "\n"])
end
def format_string(source) do
{ast, comments} =
Code.string_to_quoted_with_comments!(source,
literal_encoder: &{:ok, {:__block__, &2, [&1]}},
unescape: false,
token_metadata: true
)
{app_opts, _} = Code.eval_file(".formatter.exs")
{phoenix_opts, _} = Code.eval_file("deps/phoenix/.formatter.exs")
# we needed form_for for our .heex files, you might need more
locals_without_parens =
[form_for: 3, form_for: 4]
|> Keyword.merge(Keyword.get(app_opts, :locals_without_parens))
|> Keyword.merge(Keyword.get(phoenix_opts, :locals_without_parens))
ast
|> Macro.prewalk(&replace_routes_alias/1)
|> Macro.prewalk(&replace_route/1)
|> Code.Formatter.to_algebra(comments: comments, locals_without_parens: locals_without_parens)
|> Inspect.Algebra.format(98)
end
defp decode_literal(literal) when is_binary(literal) or is_integer(literal) do
{:ok, literal}
end
defp decode_literal({:__block__, _, [literal]}) do
{:ok, literal}
end
defp decode_literal(node), do: {:error, node}
defp encode_literal(literal) do
{:__block__, [], [literal]}
end
# alias MyAppWeb.Router.Helpers, as: Routes -> use Elixir.AppsignalMyAppWeb.VerifiedRoutes
defp replace_routes_alias(
{:alias, _,
[
{:__aliases__, _, [_web_module, :Router, :Helpers]},
[
{{:__block__, _, [:as]}, {:__aliases__, _, [:Routes]}}
]
]}
) do
{:use, [], [{:__aliases__, [], [@web_module, :VerifiedRoutes]}]}
end
defp replace_routes_alias(node), do: node
# Routes.url(MyAppWeb.Endpoint)
defp replace_route({{:., _, [{:__aliases__, _, [:Routes]}, :url]}, _, [_conn_or_endpoint]}) do
{:url, [], [{:sigil_p, [delimiter: "\""], [{:<<>>, [], ["/"]}, []]}]}
end
# Routes.static_path(conn, "/images/favicon.ico")
defp replace_route({{:., _, [{:__aliases__, _, [:Routes]}, :static_path]}, _, args}) do
[_conn_or_endpoint, path] = args
case decode_literal(path) do
{:ok, path} -> {:sigil_p, [delimiter: "\""], [{:<<>>, [], [path]}, []]}
_ -> {:sigil_p, [delimiter: "\""], [path, []]}
end
end
# Routes.static_url(conn, "/images/favicon.ico")
defp replace_route({{:., _, [{:__aliases__, _, [:Routes]}, :static_url]}, _, args}) do
[_conn_or_endpoint, path] = args
sigil =
case decode_literal(path) do
{:ok, path} -> {:sigil_p, [delimiter: "\""], [{:<<>>, [], [path]}, []]}
_ -> {:sigil_p, [delimiter: "\""], [path, []]}
end
{:url, [], [sigil]}
end
# conn |> Routes.some_path(:index, "en")
defp replace_route(
{:|>, _,
[conn_or_endpoint, {{:., _, [{:__aliases__, _, [:Routes]}, path_name]}, _, args}]}
) do
replace_route(
{{:., [], [{:__aliases__, [], [:Routes]}, path_name]}, [], [conn_or_endpoint | args]}
)
end
# Routes.some_path(conn, :action, "en", query_params)
defp replace_route(
{{:., _, [{:__aliases__, _, [:Routes]}, path_name]}, _, [_ | _] = args} = node
) do
[_conn_or_endpoint, action | params] = args
action =
case decode_literal(action) do
{:ok, action} -> action
_ -> action
end
path_name = "#{path_name}"
case find_verified_route(path_name, action, params) do
:ok -> node
route -> route
end
end
defp replace_route(node), do: node
defp find_verified_route(path_name, action, arguments) do
# pleaaaase don't have a route named Routes.product_url_path(conn, :index)
trimmed_path = path_name |> String.trim_trailing("_path") |> String.trim_trailing("_url")
route =
Phoenix.Router.routes(@web_module.Router)
|> Enum.find(fn %{helper: helper, plug_opts: plug_opts} ->
plug_opts == action && is_binary(helper) && trimmed_path == helper
end)
case route do
%{path: path} ->
{path_bits, query_params} =
path
|> String.split("/", trim: true)
|> replace_path_variables(arguments, [])
path_bits =
path_bits
|> Enum.flat_map(fn bit -> ["/", bit] end)
|> format_for_sigil_binary_args(query_params)
sigil = {:sigil_p, [delimiter: "\""], [{:<<>>, [], path_bits}, []]}
if String.ends_with?(path_name, "_url") do
{:url, [], [sigil]}
else
sigil
end
_ ->
Mix.shell().error(
"Could not find route #{path_name}, with action #{inspect(action)} and arguments #{inspect(arguments)}"
)
end
end
defp replace_path_variables([], arguments, path_bits) do
{Enum.reverse(path_bits), arguments}
end
defp replace_path_variables(path, [], path_bits) do
{Enum.reverse(path_bits) ++ path, []}
end
# conceptually /post/:post_id -> /post/#{id}
defp replace_path_variables([path_piece | rest], [arg | args], path_bits) do
if String.starts_with?(path_piece, ":") do
replace_path_variables(rest, args, [arg | path_bits])
else
replace_path_variables(rest, [arg | args], [path_piece | path_bits])
end
end
defp format_for_sigil_binary_args(path_bits, [_ | _] = query_params) do
format_for_sigil_binary_args(path_bits ++ ["?" | query_params], [])
end
defp format_for_sigil_binary_args(path_bits, []) do
path_bits
|> Enum.map(&decode_literal/1)
|> Enum.map(fn
{:ok, bit} when is_binary(bit) ->
bit
{:ok, bit} when is_atom(bit) or is_integer(bit) ->
to_string(bit)
{_, bit} ->
{:"::", [],
[
{{:., [], [Kernel, :to_string]}, [from_interpolation: true], [encode_literal(bit)]},
{:binary, [], Elixir}
]}
end)
end
# The rest is for hacking the Phoenix.LiveView.HTMLFormatter plugin to replace routes
# in .heex files. Patch deps/phoenix_live_view/lib/phoenix_live_view/html_formatter.ex:237
#
# formatted =
# source
# |> tokenize()
# |> Enum.map(&Mix.Tasks.ConvertToVerifiedRoutes.format_eex/1) # <--- Add this line
# |> to_tree([], [], {source, newlines})
#
# run with
# mix compile && mix deps.compile phoenix_live_view && mix format
# and when you're done
# mix deps.clean phoenix_live_view && mix deps.get
# tag attributes: <a href={Routes.some_path(conn, :index)}>link</a>
def format_eex({:tag, tag, attrs, meta}) do
{:tag, tag, Enum.map(attrs, &format_eex/1), meta}
end
# tag attributes of components: <._component path={Routes.some_path(conn, :index)} />
def format_eex({component, tag, attrs, meta})
when component in [:remote_component, :local_component] do
{component, tag, Enum.map(attrs, &format_eex/1), meta}
end
# attribute: href={Routes.some_path(conn, :index)}
def format_eex({attribute, {:expr, expr, meta}, attr_meta}) do
expr =
case String.contains?(expr, "Routes") && check_string(expr) do
{:ok, expr} -> expr
_ -> expr
end
{attribute, {:expr, expr, meta}, attr_meta}
end
# Eex expression: <%= Routes.some_path(conn, :index) %>
def format_eex({:eex, :expr, expr, meta}) do
expr =
case String.contains?(expr, "Routes") && check_string(expr) do
{:ok, expr} -> expr
_ -> expr
end
{:eex, :expr, expr, meta}
end
# Eex block: <%= if(url == Routes.some_path(conn, :index)) do %>
# warning, this part is frail, and relies on blocks ending in -> not having parens
# like <%= form_for @conn, Routes.some_path(conn, :index), args, fn f -> %>
# add functions to locals_without_parens in format_string/1 if needed
def format_eex({:eex, :start_expr, expr, meta}) do
expr =
case String.contains?(expr, "Routes") && check_string(expr <> " nil end") do
{:ok, expr} ->
expr
|> String.trim_trailing("end")
|> String.trim_trailing()
|> String.trim_trailing("nil")
|> String.trim_trailing()
_ ->
expr
end
{:eex, :start_expr, expr, meta}
end
def format_eex(node), do: node
defp check_string(source) do
case format_string(source) do
:ok -> :error
io_data -> {:ok, IO.iodata_to_binary(io_data)}
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment