DEV Community

Cover image for Build your own library to render JSON response in Phoenix
Dung Nguyen for OnPoint Vietnam

Posted on • Edited on

Build your own library to render JSON response in Phoenix

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

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

Enter fullscreen mode Exit fullscreen mode

Let's improve it

1. Use Map.take

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

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:

    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
Enter fullscreen mode Exit fullscreen mode

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

    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
Enter fullscreen mode Exit fullscreen mode

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:

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

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>
Enter fullscreen mode Exit fullscreen mode

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

 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
Enter fullscreen mode Exit fullscreen mode

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

Now you can improve it to render list of relationships

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
Enter fullscreen mode Exit fullscreen mode

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

@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
Enter fullscreen mode Exit fullscreen mode

Move them to a helper

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

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)