Tutorial
Implement HTML redirects with phoenix plug
This post was updated 27 Sep - 2022
In this tutorial, I will go through how you can add redirects to your Phoenix application. The goal is to have a CRUD interface in my admin where I can go in and setup a redirect path for a certain path. I should also be able to automatically add query params and chose if the redirect shuld be permanent or temporary.
Step 1 - Generate the Admin CRUD for managing redirects
In my example I am using the SAAS StarterKit boilerplate, that you can download for free here: https://livesaaskit.com/starterkit/new
That comes with a resource generator so I can run this command:
mix saaskit.gen.resource Redirects Redirect redirects path redirect_to type
NOTE: This is a simple feature and I only need the three columns.
And when I get prompted with adding an admin CRUD, I answer yes.
Select Y to add admin interface<br>
When the generator has run, I get prompted with adding the admin routes in the routes file. So, I need to open up the router and paste these in:
scope "/admin", ExampleWeb.Admin, as: :admin do
pipe_through :browser
...
live "/redirects", RedirectLive.Index, :index
live "/redirects/new", RedirectLive.Index, :new
live "/redirects/:id/edit", RedirectLive.Index, :edit
live "/redirects/:id", RedirectLive.Show, :show
live "/redirects/:id/show/edit", RedirectLive.Show, :edit
end
After I change the routes file and before I run the migrations, I need to modify the migration file. Since I now that I need to make lookups in the database based on the path, and the path should be unique, I want to add a unique-index. This ensures faster lookups and that a path can only have one redirect.
create unique_index(:redirects, :path)
When that is done, I can run the migrations
mix ecto.migrate
Next thing I need to do is to modify the Schema file and the changeset pipeline. I want to change this to use Ecto enum because I store the type of redirect in the type column and it can only have one of two values that corresponds to Plugs status codes. That is :moved_permanently
or :temporary_redirect
NOTE: The type of redirect is important for SEO if its an external page that is scraped by Google.
So, I can change the type field to be an Ecto Enum and will also set a default.
# lib/example/redirects/redirect.ex
field :type, Ecto.Enum, values: [:moved_permanently, :temporary_redirect], default: :moved_permanently
Next thing I want to do is to make sure that all paths starts with a slash. So I will add a function in the changeset pipeline that looks for paths that doesnt start with a slash and just adds it. IMO, this is a better user experience than to have a validation error.
# lib/example/redirects/redirect.ex
def changeset(redirect, attrs) do
redirect
|> cast(attrs, [:path, :redirect_to, :type])
|> validate_required([:path, :redirect_to, :type])
|> unique_constraint(:path, name: :redirects_path_index)
|> maybe_add_initial_slash(:path)
|> maybe_add_initial_slash(:redirect_to)
end
defp maybe_add_initial_slash(changeset, attr) do
case get_change(changeset, attr) do
nil -> changeset
"/" <> _path_string -> changeset
path_string -> put_change(changeset, attr, "/#{path_string}"
end
end
I also need an easy way to find the redirect in the database by path so I can add this function in my redirects context:
# lib/example/redirects.ex
def get_redirect_by_path(path) do
Repo.get_by(Redirect, path: path)
end
NOTE: This should return nil
if a redirect is not found.
STEP 2 - Add the redirects plug
With this in place, I can now add the redirects plug.
# lib/example_web/plugs/redirects.ex
defmodule ExampleWeb.Plugs.Redirects do
@moduledoc """
This plug is responsible for maybe redirecting requests from one path
to another.
"""
def init(options), do: options
def call(conn, _opts) do
with "GET" <- conn.method,
%{} = redirect <- get_redirect(conn.request_path) do
redirect_to =
URI.new!(redirect.redirect_to)
|> maybe_put_query_string(conn.query_string)
|> URI.to_string()
conn
|> Plug.Conn.put_status(redirect.type)
|> Phoenix.Controller.redirect(to: redirect_to)
|> Plug.Conn.halt()
else
_ ->
conn
end
end
defp get_redirect(path) do
Example.Redirects.get_redirect_by_path(path)
end
defp maybe_put_query_string(uri, ""), do: uri
defp maybe_put_query_string(uri, query_string) do
struct!(uri, query: query_string)
end
end
And I can test the plug with:
# test/example_web/plugs/redirects_test.exs
defmodule ExampleWeb.Plugs.RedirectsTest do
use ExampleWeb.ConnCase, async: true
import Example.RedirectsFixtures
alias ExampleWeb.Plugs.Redirects
describe "call when there is a redirect" do
test "redirects away to admin dashboard path" do
redirect = redirect_fixture()
conn =
Plug.Test.conn(:get, redirect.path, %{})
|> Redirects.call([])
assert conn.halted
assert redirected_to(conn, 301) == redirect.redirect_to
end
end
describe "call when there is no redirect" do
test "doesnt redirect" do
conn =
Plug.Test.conn(:get, "/some-path", %{})
|> Redirects.call([])
refute conn.halted
end
end
end
And I can see that the test passes. That is all and well, but I would like to test the plug used in the application as well.
STEP 3 - Use the plug in the app
To make sure that it works, I need to setup the routes that I want to redirect from and to. Also, I need to mount my plug somewhere.
I have defined a new pipeline called :maybe_redirect
that I then use in one of the scope-blocks. And in the scope-block I define the old and new routes
# lib/example_web/router.ex
pipeline :maybe_redirect do
plug ExampleWeb.Plugs.Redirects
end
scope "/", ExampleWeb do
pipe_through [:browser, :maybe_redirect]
live "/old-url", PageLive, :index
live "/new-url", PageLive, :index
end
Since this is for testing purpose only, I point them to the same LiveView page.
And the test for this looks like:
# test/example_web/live/page_live_test.exs
test "redirects correctly when the path should be redirected", %{conn: conn} do
redirect_fixture(%{path: "/old-url", redirect_to: "/new-url"})
{:error, {:redirect, %{flash: %{}, to: "/new-url"}}} = live(conn, "/old-url")
{:error, {:redirect, %{flash: %{}, to: "/new-url?some=param"}}} = live(conn, "/old-url?some=param")
end
The tests passes and I can now see that the redirect logic works and even in the case where I send params.
Caching?
Depending on your pageload, there might be an idea to cache the queries. I have a tutorial about implementing caching with Cachex here: https://fullstackphoenix.com/quick_tips/liveview-caching