How to Redirect from the Phoenix Router

Zachary Porter, Former Senior Developer

Article Categories: #Code, #Back-end Engineering

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

Related Articles