JSON Views in Phoenix

By Jason Stiebs on March 17th 2015

Lately there has been discussion around rendering Ecto models for a JSON style API in Phoenix. I am part of the core Phoenix team, and I typically use Phoenix to build API's. I am here to show you an example of using views to render JSON.

Right now there is a very common meme to use Poison Protocols to render your JSON object.

Here is an example from the #elixir-lang IRC channel:

defmodule MyApp.MyModelController do
  use MyApp.Web, :controller
  plug :action

  def show(conn, params) do
    json conn, Repo.get!(MyModel, params[:id])
  end
end
defimpl Poison.Encoder, for: MyModel do
  # This is a sigil that produces a list of atoms
  @attributes ~W(id body title user comments inserted_at)

  def encode(comment, _options) do
    comment
    |> Map.take(@attributes)
    |> Map.update!(:title, &String.capitalize)
    |> Poison.encode!
  end
end

Using the Phoenix.Controller.json/2 to return directly from your controller actions.

And while this is perfectly valid Elixir and Phoenix, in this blog post I want to highly advise against using this method.

So you might be thinking what's wrong with that method? It shows off the power of Elixir Protocols and handles your model in every case.

By using protocols, you are coupling the presentation or view to the model. Phoenix is an MVC framework, and models will always need to viewed differently in different contexts.

So what's the better way?

Phoenix Views of course!

Let's start with the view. We'll use Paul's example from above.

defmodule MyApp.MyModelController do
  use MyApp.Web, :controller
  plug :action

  def show(conn, params) do
    render conn, "show.json", data: Repo.get!(MyModel, params[:id])
  end
end
defmodule MyApp.MyModelView do
  use MyApp.Web, :view
  @attributes ~W(id body title user comments inserted_at)

  def render("show.json", %{data: data}) do
    data
    |> Map.take(@attributes)
    |> Map.update!(:title, &String.capitalize)
  end
end

Let's discuss how this is different.

  1. Explicitly rendering your data via function call. No protocols, only functions.
  2. All of your helper functions defined in the view are automatically available.
  3. If you had to modify your output, it would be obvious what to do next. Go check the view.

So this is a pretty simple example, but even in it there lie some bugs.

The first and less interesting bug, is what happens if my associations are not pre-loaded?

In both examples Ecto will raise an exception.

How are the associated model's user and comments rendered?

In the Poison example, it would attempt to apply the encoder protocol on the associations struct, and if it couldn't, it would fall back to the basic struct. In our example, the same thing would happen! We haven't explicitly defined the user or comment implementation.

Here is how I would update our example to properly render the associations:

defmodule MyApp.CommentView do
  use MyApp.Web, :view
  @attributes ~W(id name inserted_at)

  def render("show.json", %{data: comments}) when is_list(comments) do
    for comment <- comments do
      render("show.json", data: comment)
    end
  end

  def render("show.json", data: comment) do
    comment
    |> Map.take(@attributes)
    |> Map.put(:user, UserView.render('show_lite.json', data: data.user)
  end
end
defmodule MyApp.MyModelView do
  use MyApp.Web, :view
  @attributes ~W(id body title inserted_at)

  def render("show.json", %{data: my_model})
    my_model
    |> Map.take(@attributes)
    |> Map.update!(:title, &String.capitalize)
    |> Map.put(:user, UserView.render("show.json", data: my_model.user)
    |> Map.put(:comments, CommentView.render("show.json", data: my_model.comments)
  end
end
defmodule MyApp.UserView do
  use MyApp.Web, :view
  @attributes ~W(id name inserted_at)

  def render("show.json", %{data: user})
    render("show_lite.json", data: user)
    |> Map.put(:image, ImageView.render("show.json", data: user.image)
  end

  def render("show_lite.json", data: user)
    user
    |> Map.take(@attributes)
  end
end

You'll notice my view code has grown quite a bit. I added an encode function for simplicity of calling it from other views. I might even redefine the encode functions to look like this.

def render("show.json", %{data: data}) do
  %{
    id: data.id,
    name: data.name,
    title: String.capitalize!(data.name)
  }
end

Because I find it more obvious what is going on and can see at a glance what the JSON body might look like.

I want to point your attention to my encode_lite/1 function in the UserView. My CommentView only requires the basic info about a user. To accomplish this I defined a new function body and called it.

In the case of the Poison.Encoder I would need to explicitly call Poison.encode!(comment.user, lite: true) and have a conditional in my user implementation to handle the option of lite.

As the API project grows and your services become more complex, cases like these become the rule instead of the exception. Views give you the flexibility required to do basically anything you want, it's just function calls and maps.

Next time I'll show a more complex example that uses the JSON API spec and I'll show off some helpers I've built to reduce some of the obvious duplication in my views.

RokkinCat

is a software engineering agency. We build applications and teach businesses how to use new technologies like machine learning and virtual reality. We write about software, business, and business software culture.