How to Redirect from the Phoenix Router
Zachary Porter, Former Senior Developer
Article Categories:
Posted on
Come along with me as we walk through how to redirect from the Phoenix router using Test-Driven Development (TDD).
Introduction
In this article, we'll walk through how to redirect from the Phoenix router. This article assumes some prior knowledge around Plug and the Phoenix Router. All of the following code has been tested with Elixir 1.4 and Phoenix 1.2. Your mileage may vary depending on versions. We'll make a StarshipTravel
app. If you'd like to follow along, install Phoenix, and run mix phoenix.new starship_travel --no-ecto --no-brunch --no-html
.
Redirecting to Internal Routes
We'll validate that our code works as expected by starting with a test at test/starship_travel/redirector_test.exs
(like the good TDDer that I know you are). In this test, let's setup a simple Router to define routes to test against. Here's what the router will look like in the test:
defmodule Router do
use Phoenix.Router
get "/tatooine", StarshipTravel.Redirector, to: "/alderaan"
end
In this simple router, we define a route to redirect any requests for /tatooine
to /alderaan
. We will accomplish that through the StarshipTravel.Redirector
plug module, which we'll get to in a bit. Now, let's test that redirect functionality we just described.
test "route redirected to internal route" do
conn = call(Router, :get, "/tatooine")
assert conn.status == 302
assert String.contains?(conn.resp_body, "href=\"/alderaan\"")
end
The assertions above test for the redirect and that the correct path shows up in the response body. What is that call
function doing? The call
function takes the router module and invokes it with an HTTP verb. In this case, :get the path /tatooine
. Let's define that function:
defp call(router, verb, path) do
verb
|> Plug.Test.conn(path)
|> router.call(router.init([]))
end
Here, we use the HTTP verb and the request path to create a new Plug connection struct. The router is then initialized and called with the connection struct. That connection struct is returned from this function and used in the test above.
Here's what our final test file looks like after a bit of cleanup:
defmodule StarshipTravel.RedirectorTest do
use ExUnit.Case, async: true
use Plug.Test
alias StarshipTravel.Redirector
defmodule Router do
use Phoenix.Router
get "/tatooine", Redirector, to: "/alderaan"
end
test "route redirected to internal route" do
conn = call(Router, :get, "/tatooine")
assert conn.status == 302
assert String.contains?(conn.resp_body, "href=\"/alderaan\"")
end
defp call(router, verb, path) do
verb
|> Plug.Test.conn(path)
|> router.call(router.init([]))
end
end
Let's run it to get the following error:
** (Plug.Conn.WrapperError) ** (UndefinedFunctionError)
function StarshipTravel.Redirector.init/1 is undefined
(module StarshipTravel.Redirector is not available)
Perfect. The error tells us that the Plug module doesn't exist. Let's write some boilerplate Plug code in lib/starship_travel/redirector.ex
.
defmodule StarshipTravel.Redirector do
import Plug.Conn
@spec init(Keyword.t) :: Keyword.t
def init(default), do: default
@spec call(Plug.Conn.t, Keyword.t) :: Plug.Conn.t
def call(conn, opts) do
conn
end
end
As specified in Plug's documentation, a Plug module defines 2 functions: init
and call
. The init
function initializes with the given options. The call
function receives the connection and initialized options and returns the connection.
Now, let's break down this route:
get "/tatooine", StarshipTravel.Redirector, to: "/alderaan"
StarshipTravel.Redirector
is our module plug and to: "/alderaan"
are the options passed to the init
function. We want to always require the to
option to be specified, so let's modify the init
function to handle that.
def init([to: _] = opts), do: opts
def init(_default), do: raise("Missing required to: option in redirect")
Pattern matching ensures the to
option is defined in our route. If it isn't, the init
function raises an exception. Let's add a test to cover this case.
defmodule Router do
use Phoenix.Router
get "/tatooine", Redirector, to: "/alderaan"
# Add the route to raise the exception
get "/exceptional", Redirector, []
end
test "an exception is raised when `to` isn't defined" do
assert_raise Plug.Conn.WrapperError, ~R[Missing required to: option in redirect], fn ->
call(Router, :get, "/exceptional")
end
end
Great! That test passes, so let's move on with our redirect in the call
function.
@spec call(Plug.Conn.t, Keyword.t) :: Plug.Conn.t
def call(conn, opts) do
conn
|> Phoenix.Controller.redirect(opts)
end
Here, we simply call out to Phoenix's redirect/2
function with the passed in to
option. This turns our test from red to green. Progress!
Forwarding the Query String #
In some cases, we'll want the request query string to be forwarded with the redirect (e.g. tracking parameters). We can make some simple edits to our Redirector to handle such a case, but first, let's write a test.
test "route redirected to internal route with query string" do
conn = call(Router, :get, "/tatooine?gtm_a=starports")
assert conn.status == 302
assert String.contains?(conn.resp_body, "href=\"/alderaan?gtm_a=starports\"")
end
Running the test results in a failure. Time to update our Redirector to take the test from red to green.
@spec call(Plug.Conn.t, Keyword.t) :: Plug.Conn.t
def call(conn, [to: to]) do
conn
|> Phoenix.Controller.redirect(to: append_query_string(conn, to))
end
@spec append_query_string(Plug.Conn.t, String.t) :: String.t
defp append_query_string(%Plug.Conn{query_string: ""}, path), do: path
defp append_query_string(%Plug.Conn{query_string: query}, path), do: "#{path}?#{query}"
Here, we've updated the call
function to match the to
parameter and passed that to an append_query_string
function. The append_query_string
function handles 2 different cases. When the request query string is blank, then return the path. Otherwise, append the request query string to the path.
Run the tests again to ensure all green. Hooray, we've successfully passed along our tracking parameters to the redirected path! What could possibly be next for our Redirector?
Handling External Requests #
The eagle-eyed among you may have noticed that our tests mentioned "internal route". Now, we need to handle the other side of the spectrum: redirecting to a URL outside of our application.
You know the drill by now, it's test time.
test "route redirected to external route" do
conn = call(Router, :get, "/hoth")
assert conn.status == 302
assert String.contains?(conn.resp_body, "href=\"https://duckduckgo.com/?q=hoth&ia=images&iax=1\"")
end
We're going to define an external redirect from /hoth
to https://duckduckgo.com/?q=hoth&ia=images&iax=1
, which is a DuckDuckGo search for images of the planet Hoth. Running the test results in:
** (Phoenix.Router.NoRouteError) no route found for GET /hoth (StarshipTravel.RedirectorTest.Router)
Let's define that route in our test file's Router
module using an external
key this time just like the Phoenix.Controller.redirect
function.
get "/hoth", Redirector, external: "https://duckduckgo.com/?q=hoth&ia=images&iax=1"
Running the test again results in Missing required to: option in redirect
. That's the exception we raised for the missing router option, so let's head over to our Redirector
module and add an external
option.
@spec init(Keyword.t) :: Keyword.t
def init([to: _] = opts), do: opts
def init([external: _] = opts), do: opts
def init(_default), do: raise("Missing required to: / external: option in redirect")
This change is going to cause our exception test to fail, so let's update the message there.
test "an exception is raised when `to` or `external` isn't defined" do
assert_raise Plug.Conn.WrapperError, ~R[Missing required to: / external: option in redirect], fn ->
call(Router, :get, "/exceptional")
end
end
Now, our test failure is pointing us to a missing definition for the call
function. Let's go ahead and add a definition for that.
def call(conn, [external: url]) do
conn
|> Phoenix.Controller.redirect(external: url)
end
Yay, our tests are back to green! But maybe you've noticed that we have introduced some duplication in our call
function, so let's go ahead and clean that up now.
A Minor Refactoring #
The common pattern between the two call
definitions is the use of Phoenix.Controller.redirect
, so we'll just import that function specifically to use in both places. Our Redirector module will be updated like so:
import Phoenix.Controller, only: [redirect: 2]
@spec call(Plug.Conn.t, Keyword.t) :: Plug.Conn.t
def call(conn, [to: to]) do
redirect(conn, to: append_query_string(conn, to))
end
def call(conn, [external: url]) do
redirect(conn, external: url)
end
All tests pass, which is a good sign that everything is working correctly. Now, there's just one more thing left to do. Can you guess what it is?
Merging Query Strings #
That's right, we're back to the query strings. Except now, we will be merging the source and destination query strings as our destination can contain query string values. For this specific implementation, we'll give precedence for any overlapping values to the source query string, but you're welcome to change as you see fit.
Here's the test for our final feature:
test "route redirected to external route merges query string" do
conn = call(Router, :get, "/hoth?q=endor")
assert conn.status == 302
assert String.contains?(conn.resp_body, "href=\"https://duckduckgo.com/?q=endor&ia=images&iax=1\"")
end
As you can see from the test, we expect the request query string value of q
to overwrite the q
value specified in our rule. When running the test, we see that it fails when merging the query for endor
over hoth
. Let's fix our call
function to handle that, leaning heavily on Elixir's URI
module and Map.merge/2
.
def call(conn, [external: url]) do
external = url
|> URI.parse
|> merge_query_string(conn)
|> URI.to_string
redirect(conn, external: external)
end
@spec merge_query_string(URI.t, Plug.Conn.t) :: URI.t
defp merge_query_string(%URI{query: destination} = destination_uri, %Plug.Conn{query_string: source}) do
# Use Map.merge to merge the source query into the destination query
merged_query = Map.merge(
URI.decode_query(destination),
URI.decode_query(source)
)
# Return a URI struct with the merged query
%{destination_uri | query: URI.encode_query(merged_query)}
end
Hmmm our tests are failing now. If we dig into the response body to figure out why, we'll see that the query string parameters are in a different order. The documentation for Map.merge
states:
"There are no guarantees about the order of keys in the returned keyword."
This will making testing the query string values as we've been doing impossible. We need a different approach.
Let's lean on the URI
module again to parse both the actual and expected URLs and compare each of their relevant attributes like so:
test "route redirected to external route" do
conn = call(Router, :get, "/hoth")
assert_redirected_to(conn, "https://duckduckgo.com/?q=hoth&ia=images&iax=1")
end
test "route redirected to external route merges query string" do
conn = call(Router, :get, "/hoth?q=endor")
assert_redirected_to(conn, "https://duckduckgo.com/?q=endor&ia=images&iax=1")
end
defp assert_redirected_to(conn, expected_url) do
actual_uri = conn
|> Plug.Conn.get_resp_header("location")
|> List.first
|> URI.parse
expected_uri = URI.parse(expected_url)
assert conn.status == 302
assert actual_uri.scheme == expected_uri.scheme
assert actual_uri.host == expected_uri.host
assert actual_uri.path == expected_uri.path
if actual_uri.query do
assert Map.equal?(
URI.decode_query(actual_uri.query),
URI.decode_query(expected_uri.query)
)
end
end
Here, we've written an assert_redirected_to
helper function that will encapsulate all of our redirection assertions. Within that function, we parse both the location response header from the connection as well as the given expected URL and compare their relevant attributes (scheme, host, path, query). We can update the rest of our tests to use this new assertion helper function.
test "route redirected to internal route" do
conn = call(Router, :get, "/tatooine")
assert_redirected_to(conn, "/alderaan")
end
test "route redirected to internal route with query string" do
conn = call(Router, :get, "/tatooine?gtm_a=starports")
assert_redirected_to(conn, "/alderaan?gtm_a=starports")
end
test "route redirected to external route" do
conn = call(Router, :get, "/hoth")
assert_redirected_to(conn, "https://duckduckgo.com/?q=hoth&ia=images&iax=1")
end
test "route redirected to external route merges query string" do
conn = call(Router, :get, "/hoth?q=endor")
assert_redirected_to(conn, "https://duckduckgo.com/?q=endor&ia=images&iax=1")
end
This is a lot easier to read and understand what's under test.
A Gotcha #
Disqus user lumannnn pointed an error out in the comments with our implementation of merging query strings for external URLs. Here's a test case that demonstrates the error:
test "route redirected to external route" do
conn = call(Router, :get, "/bespin")
assert_redirected_to(conn, "https://duckduckgo.com/")
end
test "route redirected to external route with query string" do
conn = call(Router, :get, "/bespin?q=bespin")
assert_redirected_to(conn, "https://duckduckgo.com/?q=bespin")
end
The resulting exception when running the tests indicates that we didn't handle when the Destination URI is missing a query string. Let's fix that up with another merge_query_string
function definition that catches when the Destination URI's query string is nil
:
# Add this function def!
defp merge_query_string(%URI{query: nil} = destination_uri, %Plug.Conn{query_string: source}) do
%{destination_uri | query: source}
end
defp merge_query_string(%URI{query: destination} = destination_uri, %Plug.Conn{query_string: source}) do
merged_query = Map.merge(
URI.decode_query(destination),
URI.decode_query(source)
)
%{destination_uri | query: URI.encode_query(merged_query)}
end
Re-running the test results in green! Bug patched up. Thanks to lumannnn for pointing out the error!
Summary #
We did it! We successfully wrote a module for redirecting from within our Phoenix Router. I hope you learned a thing or two on this journey. Leave any errata or suggestions in a comment below. Thanks for coming along!
Here's the final code in its entirety:
# -----------------------------------
# lib/starship_travel/redirector.ex
# -----------------------------------
defmodule StarshipTravel.Redirector do
import Phoenix.Controller, only: [redirect: 2]
@spec init(Keyword.t) :: Keyword.t
def init([to: _] = opts), do: opts
def init([external: _] = opts), do: opts
def init(_default), do: raise("Missing required to: / external: option in redirect")
@spec call(Plug.Conn.t, Keyword.t) :: Plug.Conn.t
def call(conn, [to: to]) do
redirect(conn, to: append_query_string(conn, to))
end
def call(conn, [external: url]) do
external = url
|> URI.parse
|> merge_query_string(conn)
|> URI.to_string
redirect(conn, external: external)
end
@spec append_query_string(Plug.Conn.t, String.t) :: String.t
defp append_query_string(%Plug.Conn{query_string: ""}, path), do: path
defp append_query_string(%Plug.Conn{query_string: query}, path), do: "#{path}?#{query}"
@spec merge_query_string(URI.t, Plug.Conn.t) :: URI.t
defp merge_query_string(%URI{query: nil} = destination_uri, %Plug.Conn{query_string: source}) do
%{destination_uri | query: source}
end
defp merge_query_string(%URI{query: destination} = destination_uri, %Plug.Conn{query_string: source}) do
merged_query = Map.merge(
URI.decode_query(destination),
URI.decode_query(source)
)
%{destination_uri | query: URI.encode_query(merged_query)}
end
end
# -----------------------------------
# test/starship_travel/redirector_test.exs
# -----------------------------------
defmodule StarshipTravel.RedirectorTest do
use ExUnit.Case, async: true
alias StarshipTravel.Redirector
defmodule Router do
use Phoenix.Router
get "/tatooine", Redirector, to: "/alderaan"
get "/exceptional", Redirector, []
get "/bespin", Redirector, external: "https://duckduckgo.com/"
get "/hoth", Redirector, external: "https://duckduckgo.com/?q=hoth&ia=images&iax=1"
end
test "an exception is raised when `to` or `external` isn't defined" do
assert_raise Plug.Conn.WrapperError, ~R[Missing required to: / external: option in redirect], fn ->
call(Router, :get, "/exceptional")
end
end
test "route redirected to internal route" do
conn = call(Router, :get, "/tatooine")
assert_redirected_to(conn, "/alderaan")
end
test "route redirected to internal route with query string" do
conn = call(Router, :get, "/tatooine?gtm_a=starports")
assert_redirected_to(conn, "/alderaan?gtm_a=starports")
end
test "route redirected to external route" do
conn = call(Router, :get, "/bespin")
assert_redirected_to(conn, "https://duckduckgo.com/")
end
test "route redirected to external route with query string" do
conn = call(Router, :get, "/bespin?q=bespin")
assert_redirected_to(conn, "https://duckduckgo.com/?q=bespin")
end
test "route redirected to external route merges query string" do
conn = call(Router, :get, "/hoth?q=endor")
assert_redirected_to(conn, "https://duckduckgo.com/?q=endor&ia=images&iax=1")
end
defp call(router, verb, path) do
verb
|> Plug.Test.conn(path)
|> router.call(router.init([]))
end
defp assert_redirected_to(conn, expected_url) do
actual_uri = conn
|> Plug.Conn.get_resp_header("location")
|> List.first
|> URI.parse
expected_uri = URI.parse(expected_url)
assert conn.status == 302
assert actual_uri.scheme == expected_uri.scheme
assert actual_uri.host == expected_uri.host
assert actual_uri.path == expected_uri.path
if actual_uri.query do
assert Map.equal?(
URI.decode_query(actual_uri.query),
URI.decode_query(expected_uri.query)
)
end
end
end