Skip to content

Instantly share code, notes, and snippets.

@andreaseriksson
Last active March 31, 2024 12:29
Show Gist options
  • Star 24 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save andreaseriksson/e454b9244a734310d4ab74d8595f98cd to your computer and use it in GitHub Desktop.
Save andreaseriksson/e454b9244a734310d4ab74d8595f98cd to your computer and use it in GitHub Desktop.
This is a mix task for converting old Phoenix routes to new verified routes
defmodule Mix.Tasks.ConvertToVerifiedRoutes do
@shortdoc "Fix routes"
use Mix.Task
@regex ~r/(Routes\.)(.*)_(path|url)\(.*?\)/
@web_module MyAppWeb
def run(_) do
Path.wildcard("lib/**/*.*ex")
|> Enum.concat(Path.wildcard("test/**/*.*ex*"))
|> Enum.sort()
|> Enum.filter(&(File.read!(&1) |> String.contains?("Routes.")))
|> Enum.reduce(%{}, fn filename, learnings ->
test_filename(filename, learnings)
end)
:ok
end
def test_filename(filename, learnings) do
Mix.shell().info(filename)
content = File.read!(filename)
case replace_content(content, learnings) do
{:ok, content, learnings} ->
File.write!(filename, content)
learnings
_ ->
learnings
end
end
def replace_content(content, learnings) do
case Regex.run(@regex, content) do
[route|_] -> ask_about_replacement(content, route, learnings)
_ -> {:ok, content, learnings}
end
end
def ask_about_replacement(content, route, learnings) do
route = String.trim(route)
if verified_route = find_verified_route_from_string(route) do
replacement = Map.get(learnings, route) || ask_for_direct_match(route, verified_route) || ask_for_fallback(route, verified_route)
if replacement && (String.starts_with?(replacement, "~p\"/") || String.starts_with?(replacement, "url")) do
replace_content(
String.replace(content, route, replacement),
Map.put(learnings, route, replacement)
)
end
else
{:ok, content, learnings}
end
end
def ask_for_fallback(route, _verified_route) do
response = Mix.shell().prompt(
"""
What is the verified route for (type "skip" for skipping):
#{IO.ANSI.red}#{route}#{IO.ANSI.reset}
Start with:
~p"/..
""")
response = String.trim("#{response}")
response != "" && response
end
def ask_for_direct_match(route, verified_route) do
if Mix.shell().yes?(
"""
Should we replace
#{IO.ANSI.red}#{route}#{IO.ANSI.reset}
with
#{IO.ANSI.green}#{verified_route}#{IO.ANSI.reset}
""") do
verified_route
end
end
def find_verified_route_from_string(route) do
parts =
route
|> String.replace("Routes.", "")
|> String.split("(")
with [route_helper, arguments|_] <- parts do
arguments =
arguments
|> String.replace_trailing(")", "")
|> String.split(",")
|> Enum.map(&String.trim/1)
@web_module.Router.__routes__()
|> Enum.find(fn %{helper: helper, plug_opts: plug_opts} ->
is_atom(plug_opts) &&
Enum.member?(arguments, ":#{plug_opts}") &&
String.starts_with?(route_helper, helper)
end)
|> case do
%{path: "" <> path} ->
path = interpolate_path_with_vars(path, arguments)
path = maybe_add_params(path, arguments)
if String.contains?(route_helper, "_url") do
~s[url(~p"#{path}")]
else
~s(~p"#{path}")
end
_ ->
nil
end
end
end
def interpolate_path_with_vars(path, arguments) do
arguments = Enum.slice(arguments, 2..10)
path
|> String.split("/")
|> Enum.filter(&String.starts_with?(&1, ":"))
|> Enum.with_index()
|> Enum.reduce(path, fn {slot, idx}, memo ->
argument = Enum.at(arguments, idx)
argument = "{#{argument}}"
String.replace(memo, slot, "##{argument}", global: false)
end)
end
def maybe_add_params(path, arguments) do
case Enum.filter(arguments, &String.contains?(&1, ": ")) do
[] ->
if "params" in arguments do
query_params = "{params}"
"#{path}?##{query_params}"
else
path
end
query_params ->
query_params = "{#{inspect(query_params) |> String.replace("\"", "") }}"
"#{path}?##{query_params}"
end
end
end
@andreaseriksson
Copy link
Author

Added some simple support for params

@brainlid
Copy link

brainlid commented Jun 5, 2023

Thanks for the tool! I wrote up my experience using this and how to clean-up some of the left-over bits. https://fly.io/phoenix-files/migrating-to-verified-routes/

@fastjames
Copy link

fastjames commented Jun 16, 2023

I followed the fly article and found that the task also seems to have trouble with nested routes (e.g. Routes.user_comments_path(conn, @user) would be changed to ~p"/users/#{@user}", leaving off the trailing /comments).

@apoorv-2204
Copy link

apoorv-2204 commented Jul 11, 2023

* (UndefinedFunctionError) function AdminWeb.Router.__routes__/0 is undefined (module AdminWeb.Router is not available)
    AdminWeb.Router.__routes__()
    (kc_core 0.1.0) lib/mix/tasks/convert_to_verified_routes.ex:96: Mix.Tasks.ConvertToVerifiedRoutes.find_verified_route_from_string/1
    (kc_core 0.1.0) lib/mix/tasks/convert_to_verified_routes.ex:45: Mix.Tasks.ConvertToVerifiedRoutes.ask_about_replacement/3
    (kc_core 0.1.0) lib/mix/tasks/convert_to_verified_routes.ex:26: Mix.Tasks.ConvertToVerifiedRoutes.test_filename/2
    (elixir 1.14.1) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
    (kc_core 0.1.0) lib/mix/tasks/convert_to_verified_routes.ex:14: Mix.Tasks.ConvertToVerifiedRoutes.run/1
    (mix 1.14.1) lib/mix/task.ex:421: anonymous fn/3 in Mix.Task.run_task/4
    (mix 1.14.1) lib/mix/cli.ex:84: Mix.CLI.run_task/2

Running inside umbrella app for a particular version.

@apoorv-2204
Copy link

  Phoenix.Router.routes( @web_module.Router)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment