There are many reasons we might want to redirect routes in our phoenix application such as the name of a domain concept changing or the removal of a page from the application.
As an example, let’s say we have the following route /home
and we want to
change it to /welcome
:
scope "/", AppWeb do
pipe_through :browser
get "/home", HomeController, :index
end
One of the most common solutions — and perhaps the most straightforward
— is to create a WelcomeController
, rename the view module and
template file, and redirect from the old controller:
defmodule AppWeb.HomeController do
use AppWeb, :controller
def index(conn, _params) do
welcome_path = Routes.welcome_path(conn, :index)
conn |> redirect(to: welcome_path) |> halt()
end
end
And then we can reference both controllers from our router.ex
:
scope "/", AppWeb do
pipe_through :browser
get "/home", HomeController, :index
get "/welcome", WelcomeController, :index
end
Functionally, there is nothing wrong with this approach. It gets the job done, and it’s simple to implement. But there are a some significant downsides:
- We have to keep both the old controller and the new controller around
- If our redirection behavior were even slightly more complex — such as handling a URL parameter — it would feel like the old controller knew too much about the new controller’s implementation
- Someone looking at
router.ex
can’t know whetherget "/home", HomeController, :index
is rendering a page that exists, or performing a redirection - We can’t test the redirection behavior without writing a controller test
The solution to these problems is to extract the redirection logic and responsibility away from the controller.
Where should that logic live? In a Plug, of course!
Redirecting a single route
The Phoenix.Router.forward/4
macro allows us to forward requests made for a
given path to a named plug; for example:
scope "/", AppWeb do
pipe_through :browser
get "/welcome", WelcomeController, :index
forward "/home", Plugs.WelcomePageRedirector
end
Even without knowing the implementation details of Plugs.WelcomePageRedirector
, it is still clear from a quick look looking at the route definition that the /home
route is performing a redirect.
If we then define Plugs.WelcomePageRedirector
in
app/lib/app_web/plugs/welcome_page_redirector.ex
, a simple implementation
might look like this:
defmodule AppWeb.Plugs.WelcomePageRedirector do
alias AppWeb.Router.Helpers, as: Routes
def init(default), do: default
def call(conn, _opts) do
welcome_path = Routes.welcome_path(conn, :index)
conn
|> Phoenix.Controller.redirect(to: welcome_path)
|> Plug.Conn.halt()
end
end
The implementation of the plug is almost identical to how we previously implemented the redirect in the controller. But encapsulating the logic into a plug means that we can easily test the plug in isolation from the controller.
Redirecting multiple routes
If you have multiple routes you want to redirect, a single plug for each route is a viable option. And there is value in keeping the plug implementations straightforward to make them easier to understand and test.
But in situations where you have a set of routes you want to redirect in a predictable way, you can write a more dynamic plug.
As an example, let’s say we have a ProfileController
that handles all
requests to the route /profile/:id/*
and we want to rename it to
UserController
and move its routes to /user/:id/*
.
The forward/4
macro forwards all requests starting with the given path, so
the following would handle any route starting with /profile
:
scope "/", AppWeb do
pipe_through :browser
resources "/user", UserController
forward "/profile", Plugs.ProfileRedirector
end
And Plugs.ProfileRedirector
has access to the request fields from
Plug.Conn
, allowing us to dynamically redirect the request in our plug
implementation based on the user id path parameter:
defmodule AppWeb.Plugs.ProfileRedirector do
alias AppWeb.Router.Helpers, as: Routes
def init(default), do: default
def call(conn, _opts) do
[id, _tail] = conn.path_info
user_path = Routes.user_path(conn, :show, id)
conn
|> Phoenix.Controller.redirect(to: user_path)
|> Plug.Conn.halt()
end
end
The above implementation does not attempt to make all resource routes work.
Instead, it simply redirects them to the :show
page under the new /user
URL
path.
This is a trade-off. We could write a complex plug that allows all requests to
the old route /profile/:id/*
to function as if they were made to
/user/:id/*
, but we can avoid that complexity if our use case is just to
redirect lost users to the new route.
Ideally we want to keep these plugs short and simple, ensuring that they are easy to understand and easy to test.