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 the Toss 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.