In part 6 of the url shortener adventure we bolt on a web front-end with help from Phoenix.
Part 6: Phoenix
In the previous post we made our application more resilient by using the filesystem to store process state. Next we need to make our application accessible from the web and this finally gave me an excuse to play around with the Phoenix web framework!
The first step of any Phoenix tutorial is using the mix command to generate a scaffold. Because I like to understand all the files that are generated and only commit what is absolutely necessary you will find that I've cut out a handful of things to keep it beginner friendly.
mix phx.new example --no-ecto --no-brunch
note: when Phoenix 1.4 lands the `--no-brunch` you see above will be replaced with `--no-webpack`
And because I'm using the scaffold generated as a guide, I'll be showing a few steps that usually don't get much love in a more traditional walk through. The first of which is that I need to add a few dependencies to the mix.exs file so Phoenix and friends are available.
defp deps do
[
{:phoenix, "~> 1.3.3"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_html, "~> 2.10"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:cowboy, "~> 1.0"},
{:plug_cowboy, "~> 1.0"}
]
end
The next step requires we alter the application.ex file to include the new endpoint module generated by mix. Note: the web application is using the module prefix `ExampleWeb` while the OTP application itself still has the prefix 'EX'. In a future refactor I'll update this `EX` prefix to be `Example` instead to better match the folder structure.
defmodule EX.Application do
use Application
def start(_type, _args) do
import Supervisor.Spec
children = [
supervisor(ExampleWeb.Endpoint, []),
{Registry, keys: :unique, name: EX.Registry},
EX.Cache,
EX.Worker
]
opts = [strategy: :one_for_one, name: EX.Supervisor]
Supervisor.start_link(children, opts)
end
def config_change(changed, _new, removed) do
ExampleWeb.Endpoint.config_change(changed, removed)
:ok
end
end
At a glance the endpoint.ex file reveals a Plug pipeline that Phoenix will use to handle incoming requests. The important bit for today is the line `plug ExampleWeb.Router` because this will direct any http request to our application's Router.
defmodule ExampleWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :example
plug Plug.Logger
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Poison
plug Plug.MethodOverride
plug Plug.Head
plug ExampleWeb.Router
def init(_key, config) do
if config[:load_from_system_env] do
port = System.get_env("PORT") || raise "expected the PORT environment variable to be set"
{:ok, Keyword.put(config, :http, [:inet6, port: port])}
else
{:ok, config}
end
end
end
The router is where you declare different contexts with `pipeline` (ie: api, browser) and the routes themselves. Today we will add the most basic `json` api allowing the application to store and retrieve short urls via http GET/POST. Once the router has determined a match it will forward on the request to our `UrlController`.
defmodule ExampleWeb.Router do
use ExampleWeb, :router
pipeline :api do
plug :accepts, ["json"]
end
scope "/api", ExampleWeb do
pipe_through(:api)
get("/:id", UrlController, :show)
post("/create", UrlController, :create)
end
end
The controller implements 2 public functions that are called from the router. When a GET request is handled we call `show` passing the id for the given link. When a POST request is handled we call `create` and use the data payload to generate a short hmac like value that will act as the unique idenifier for the shortened url. Both functions use a helper called `render` that point to our view module.
defmodule ExampleWeb.UrlController do
use ExampleWeb, :controller
def show(conn, %{"id" => id}) do
url = EX.Worker.get(:worker, "#{id}")
render(conn, "show.json", %{id: id, url: url})
end
def create(conn, %{"link" => %{"url" => url}}) do
id = hmac(url)
EX.Worker.put(:worker, id, url)
render(conn, "show.json", %{id: id, url: url})
end
defp hmac(url) do
:crypto.hmac(:sha256, "example", url)
|> Base.encode16
|> String.slice(0, 6)
end
end
The view has a `render` function that will pattern match on the inputs provided by the controller and return a map. This step isn't necessary at the moment because of how simple the use case is but I went ahead with it to see the flow from Router => Controller => View.
defmodule ExampleWeb.UrlView do
use ExampleWeb, :view
def render("show.json", %{id: id, url: url}) do
%{id: id, url: url}
end
end
To see this in action we next write an integration test to exercise the router, controller and view. You can run this from the command line using `mix test`. To list out all of the routes available use the command `mix phx.routes`
defmodule ExampleWeb.UrlControllerTest do
use ExampleWeb.ConnCase
test "GET and POST drive the url shortener", %{conn: conn} do
id = "77B5F6"
url = "google.com"
default_result = get(conn, url_path(conn, :show, id))
default = %{"id" => id, "url" => "undefined"}
assert json_response(default_result, 200) == default
expected = %{"id" => id, "url" => url}
post_result = post(conn, url_path(conn, :create, %{link: %{url: url}}))
assert json_response(post_result, 200) == expected
get_result = get(conn, url_path(conn, :show, id))
assert json_response(get_result, 200) == expected
end
end
You can also verify the application works end to end with curl. First spin up the Phoenix app from the command line.
iex -S mix phx.server
Next make a POST request with curl to insert the url you'd like to shorten with the api.
curl --request POST \
--url http://localhost:4000/api/create \
--header 'content-type: application/json' \
--data '{"link": {"url": "google.com"}}'
Finally make a GET request with curl to query the api for a url by id.
curl --request GET \
--url http://localhost:4000/api/77B5F6 \
--header 'content-type: application/json'
You can track my progress on github commit by commit. If you just want the code for this post checkout this commit.