Build your own library to render JSON response in Phoenix
Tags: elixir, phoenix
Published: 2021-07-23 08:56:48

In my previous article, I introduced my library call `JsonView` to render json response easier.

https://dev.to/bluzky/elixir-phoenix-render-ecto-schema-to-json-with-relationships-3blj

Today I will guide you to write your own Json render view. Let's start.



Now for example I have a Blog app with `User` `Category` , `Post` and `Comment` schemas.

This is `PostView` which is generated by `Phoenix`

```elixir
defmodule MyBlogWeb.PostView do
  use MyBlogWeb, :view
  alias MyBlogWeb.PostView

  def render("index.json", %{posts: posts}) do
    %{data: render_many(posts, PostView, "post.json")}
  end

  def render("show.json", %{post: post}) do
    %{data: render_one(post, PostView, "post.json")}
  end

  def render("post.json", %{post: post}) do
    %{id: post.id,
      title: post.title,
      description: post.description,
      content: post.content,
      cover: post.cover,
      is_published: post.is_published}
  end
end

```



**Let's improve it**

### 1. Use `Map.take` 

```elixir
...
def render("post.json", %{post: post}) do
		Map.take(post, [:id, :title, :description, :content, :cover, :is_published])
end
...
```

This way you don't have to write much code every time you add a new attribute.



### 2. Render custom field

You may want to:

- Format some field value instead of return original value
- Calculate virtual field



Normally you will do this:

```elixir
	def render("post.json", %{post: post}) do
    post
    |> Map.take([:id, :title, :description, :content, :cover, :is_published])
    |> Map.merge(%{
      comment_count: render_comment_count(post),
      author_name: render_author_name(post)
    })
  end

  def render_comment_count(post) do
  	...
  end

  def render_author_name(post) do
    ...
  end
```



Or you can reduce a bit of code by using pattern matching to render custom field value

```elixir
	def render("post.json", %{post: post}) do
    post
    |> Map.take([:id, :title, :description, :content, :cover, :is_published])
    |> Map.merge(render_custom_fields(post, [:comment_count, :author_name]))
  end

  defp render_custom_fields(struct, fields) do
    Enum.map(fields, fn field ->
    	{field, render_field(field, struct)}
    end)
    |> Enum.into(%{})
  end

  defp render_field(:comment_count, post) do
    ...
  end

  defp render_field(:author_name, post) do
    ...
  end
```



Now every time you add a new custom field, just add field name to the list, and define a `render_field/2` function



### 3. Render relation ship

You may want to return the whole object of author. For example you have a view `UserView` so you can do:

```elixir
 def render("post.json", %{post: post}) do
    post
    ...
    |> Map.merge(%{
      author: render_one(post.author, MyBlogWeb.UserView, "user.json")
    })
 end
```



It requires that author must be loaded, if not, you will get this error

```
** (KeyError) key :id not found in: #Ecto.Association.NotLoaded<association :author is not loaded>
```



You can handle it by pattern matching against `Ecto.Association.NotLoaded`

```elixir
 def render("post.json", %{post: post}) do
    post
    ...
    |> Map.merge(%{
      author: render_relationship(post.author, MyBlogWeb.UserView, "user.json")
    })
 end
 
 defp render_relationship(%Ecto.Association.NotLoaded{}, _, _), do: nil
 
 defp render_relationship(relation, view, template) do
    render_one(relation, view, template)
 end
```

And it only render relations struct if loaded, otherwise it is set to nil.

Now you can improve it to render list of relationships



```elixir
def render("post.json", %{post: post}) do
    post
    ...
    |> Map.merge(
      render_relationship(post, [
        {:author, MyBlogWeb.UserView, "user.json"},
        {:comments, MyBlogWeb.CommentView, "comment.json"}
      ])
    )
end

defp render_relationship(struct, relationships) do
    Enum.map(relationships, fn {field, view, template} ->
      {field, render_relationship(Map.get(struct, field), view, template)}
    end)
    |> Enum.into(%{})
end

defp render_relationship(%Ecto.Association.NotLoaded{}, _, _), do: nil

defp render_relationship(relations, view, template) when is_list(relations) do
    render_many(relations, view, template)
end

defp render_relationship(relation, view, template) do
    render_one(relation, view, template)
end
```

With this way you can handle both single struct and list of struct.



### 4. Combines these helper functions

You can combine them all in one function and only need to pass field definition to this function

```elixir
@fields [:id, :title, :description, :content, :cover, :is_published]
  @custom_fiels [:comment_count, :author_name]
  @relationships [
    {:author, MyBlogWeb.UserView, "user.json"},
    {:comments, MyBlogWeb.CommentView, "comment.json"}
  ]

  def render("post.json", %{post: post}) do
    render_json(post, @fields, @custom_fiels, @relationships)
  end

  def render_json(struct, fields, custom_fields \\ [], relationships \\ []) do
    struct
    |> Map.take(fields)
    |> Map.merge(render_custom_fields(struct, custom_fields))
    |> Map.merge(render_relationship(struct, relationships))
  end
```





## Move them to a helper

These functions are the same for every view, so let's move these code to a helper module `JsonViewHelper`

```elixir
defmodule JsonViewHelper do
  import Phoenix.View, only: [render_one: 3, render_many: 3]

  def render_json(struct, view, fields, custom_fields \\ [], relationships \\ []) do
    struct
    |> Map.take(fields)
    |> Map.merge(render_custom_fields(struct, view, custom_fields))
    |> Map.merge(render_relationship(struct, relationships))
  end

  defp render_custom_fields(struct, view, fields) do
    Enum.map(fields, fn field ->
      {field, view.render_field(field, struct)}
    end)
    |> Enum.into(%{})
  end

  defp render_relationship(struct, relationships) do
    Enum.map(relationships, fn {field, view, template} ->
      {field, render_relationship(Map.get(struct, field), view, template)}
    end)
    |> Enum.into(%{})
  end

  defp render_relationship(%Ecto.Association.NotLoaded{}, _, _), do: nil

  defp render_relationship(relations, view, template) when is_list(relations) do
    render_many(relations, view, template)
  end

  defp render_relationship(relation, view, template) do
    render_one(relation, view, template)
  end
end
```

Here I modify `render_custom_fields` a bit, because we call `render_field` to render custom field, so we have pass the view module as second parameter, so we can use the module to invoke those `render_field` that we define.



And now render json response is much simple:

```elixir
defmodule BlogeeWeb.PostView do
	...
  @fields [:id, :title, :description, :content, :cover]
  @custom_fields [:status]
  @relationships [
    {:author, BlogeeWeb.UserView, "basic_info.json"},
    {:category, BlogeeWeb.CategoryView, "category.json"}
  ]
  def render("post.json", %{post: post}) do
    JsonViewHelper.render_json(post, __MODULE__, @fields, @custom_fields, @relationships)
  end

  def render_field(:status, post) do
    if post.is_published do
      "published"
    else
      "draft"
    end
  end
end
```





## Hooray

Thank you for reading to the end of this article. Hope that this can help.

If you want to use render hook, take a look at my github for full code

https://github.com/bluzky/json_view