Coin-toss.org / Simple Phoenix application - Part 4
Summary
The purpose of this post is to document the creation process of the Coin-toss.org website using the Phoenix Web application framework and the Elixir programming languages. Part 4 steps include:
- Adding a JSON API to the application
- Adding a
Poison.Encoder
to theToss
module
Technology used:
- Elixir 1.2
- Phoenix 1.1.1
- Postgres 9.4
Adding a JSON API to the application
Adding a JSON API to a Phoenix application is incredibly simple. Fortunately you
can reuse almost all of your controller code without much modification. Let’s
jump right in - the first thing to do is to create a new set or routes that
respond to the application/json
content-type
. Phoenix has an example of
this commented out in therouter.ex
file and we can just recycle that by adding:
scope "/api/v1", CoinToss do
pipe_through :api
resources "/toss", TossController, only: [:create, :show], as: "api_toss"
end
I also gave the route a name using as: api_toss
so that we can use named route
paths in our tests rather than arbitrary urls. I also removed then [:new]
option
from the resources as a JSON API request would just hit the :create
endpoint.
We also need to make some changes in the toss_controller.ex
file as it was set up
to respond to html requests only. The biggest change is to the render
functions
where instead of calling render "show.html"
we should not call render :show
.
The change this will have is that the controller will automatically call the right
extension based on content-type
, ie: show.json
for JSON requests and show.html
for HTML requests. Let us make that change then in toss_contrroller.ex
:
def show(conn, %{"id" => id}) do
toss = Repo.get!(Toss, id)
similar = Toss
|> Toss.created_in_last_n_days(2)
|> Toss.with_specific_hash(toss.hash)
|> Repo.all
render conn, :show, toss: toss, similar: similar
end
Now we could create a file in web/templates/toss
that is named show.json
to
render our JSON template, but we could also just do it in the View
module.
Doing one or the other is fine, but for varieties sake we will do the latter. Add
the following to web/views/toss_view.ex
:
def render("show.json", %{toss: toss, similar: similar}) do
%{toss: toss, similars: similar}
end
All we are doing is pattern matching the render call created by the controller and returning a map. Phoenix is smart enough to turn that map into JSON.
We also need to update the create/2
function in toss_controller.ex
. Here we
have to make some changes. Previously we just redirected to the show/2
controller,
but this will not work any longer as a JSON POST request would probably like the actual
object back rather than a redirect. Let us make the change:
def create(conn, %{"toss" => toss_params}) do
ip = get_ip(conn)
changeset = Toss.changeset(%Toss{}, Map.merge(toss_params, %{"ip" => ip}))
case Repo.insert(changeset) do
{:ok, toss} ->
similar = get_similar(toss)
render conn, :show, toss: toss, similar: similar
{:error, changeset} ->
render(conn, :new, changeset: changeset)
end
end
You will notice that the render
functions now also call the atoms and that we
have included a new private function called get_similar/1
. All this does is
return similar tosses and keeps the code DRY because the same function utility
is also need need in show/2
. So the code now looks like this:
def show(conn, %{"id" => id}) do
toss = Repo.get!(Toss, id)
similar = get_similar(toss)
render conn, :show, toss: toss, similar: similar
end
...
defp get_similar(toss) do
Toss
|> Toss.created_in_last_n_days(2)
|> Toss.with_specific_hash(toss.hash)
|> Repo.all
end
What happens when incorrect attributes are posted to the create/2
function? In
HTML we render the form again and ask the user to repeat the entry. In JSON we do
not have that option. Instead we should render back the errors. We can do this
by adding another function to toss_view.ex
that matches new.json
:
def render("new.json", %{changeset: changeset}) do
%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
end
Adding a Poison.Encoder
to the Toss
module
The only issue now is that when we return our model as JSON, the result
field is
either a 0
or a 1
. While this is great for the internal data structure it is
not super readable for the outside world. They are more interested in knowing
whether the result was heads
or tails
. In addition they don’t need to know about
stuff like the hash. To make limit the amount of fields shown and to convert the
result into a more readable format we can assign a custom encode
function to our
module. To do that we need to implement Poison.Encoder
interface. We can just
add the following to toss.ex
:
defimpl Poison.Encoder, for: CoinToss.Toss do
def encode(toss, options) do
toss
|> Map.update!(:result, &(if &1 == 0, do: :heads, else: :tails))
|> Map.take([:id, :heads, :tails, :result, :ip, :created_at])
|> Poison.Encoder.encode(options)
end
end
All this does it update the result with a readable value and then cherry pick the attributes we want to a show a user.
Done? Not really, still need to add those tests to toss_controller_test.exs
:
test "GET /api/v1/toss/:id when a toss exists", %{conn: conn} do
toss = Toss.changeset(%Toss{}, %{heads: "A", tails: "B", ip: "C"}) |> Repo.insert!
conn = get conn, api_toss_path(conn, :show, toss)
assert json_response(conn, 200)
end
test "POST /api/v1/toss creates a new toss if all the required fiels are available", %{conn: conn} do
conn = post conn, api_toss_path(conn, :create), %{toss: %{heads: "A", tails: "B"}}
assert Repo.get_by(Toss, heads: "A")
assert json_response(conn, 200)
end
test "POST /api/v1/toss does not creates a new toss if any of the required fiels are missing", %{conn: conn} do
conn = post conn, api_toss_path(conn, :create), %{toss: %{heads: "A"}}
refute Repo.get_by(Toss, heads: "A")
assert json_response(conn, 200)
end
and mix test
:
$mix test
................................
Finished in 0.3 seconds (0.2s on load, 0.09s on tests)
32 tests, 0 failures
Conclusion
We have finally completed all the controller and model functionality, all without
really touching templates. We added a JSON API and wrote a custom Encoder
for
our model.