Elixir phoenix - Render Ecto schema to json with relationships
When writing API with Phoenix and render json to client, For some fields I want to keep it original value. For some fields, I want to do some …
In my previous article, I introduced my library call JsonView
to render json response easier. You can read it here:
Render Ecto schema to json with relationships with JsonView
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
1defmodule MyBlogWeb.PostView do
2 use MyBlogWeb, :view
3 alias MyBlogWeb.PostView
4
5 def render("index.json", %{posts: posts}) do
6 %{data: render_many(posts, PostView, "post.json")}
7 end
8
9 def render("show.json", %{post: post}) do
10 %{data: render_one(post, PostView, "post.json")}
11 end
12
13 def render("post.json", %{post: post}) do
14 %{id: post.id,
15 title: post.title,
16 description: post.description,
17 content: post.content,
18 cover: post.cover,
19 is_published: post.is_published}
20 end
21end
Let’s improve it
Map.take
1...
2def render("post.json", %{post: post}) do
3 Map.take(post, [:id, :title, :description, :content, :cover, :is_published])
4end
5...
This way you don’t have to write much code every time you add a new attribute.
You may want to:
Normally you will do this:
1 def render("post.json", %{post: post}) do
2 post
3 |> Map.take([:id, :title, :description, :content, :cover, :is_published])
4 |> Map.merge(%{
5 comment_count: render_comment_count(post),
6 author_name: render_author_name(post)
7 })
8 end
9
10 def render_comment_count(post) do
11 ...
12 end
13
14 def render_author_name(post) do
15 ...
16 end
Or you can reduce a bit of code by using pattern matching to render custom field value
1 def render("post.json", %{post: post}) do
2 post
3 |> Map.take([:id, :title, :description, :content, :cover, :is_published])
4 |> Map.merge(render_custom_fields(post, [:comment_count, :author_name]))
5 end
6
7 defp render_custom_fields(struct, fields) do
8 Enum.map(fields, fn field ->
9 {field, render_field(field, struct)}
10 end)
11 |> Enum.into(%{})
12 end
13
14 defp render_field(:comment_count, post) do
15 ...
16 end
17
18 defp render_field(:author_name, post) do
19 ...
20 end
Now every time you add a new custom field, just add field name to the list, and define a render_field/2
function
You may want to return the whole object of author. For example you have a view UserView
so you can do:
1 def render("post.json", %{post: post}) do
2 post
3 ...
4 |> Map.merge(%{
5 author: render_one(post.author, MyBlogWeb.UserView, "user.json")
6 })
7 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
1 def render("post.json", %{post: post}) do
2 post
3 ...
4 |> Map.merge(%{
5 author: render_relationship(post.author, MyBlogWeb.UserView, "user.json")
6 })
7 end
8
9 defp render_relationship(%Ecto.Association.NotLoaded{}, _, _), do: nil
10
11 defp render_relationship(relation, view, template) do
12 render_one(relation, view, template)
13 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
1def render("post.json", %{post: post}) do
2 post
3 ...
4 |> Map.merge(
5 render_relationship(post, [
6 {:author, MyBlogWeb.UserView, "user.json"},
7 {:comments, MyBlogWeb.CommentView, "comment.json"}
8 ])
9 )
10end
11
12defp render_relationship(struct, relationships) do
13 Enum.map(relationships, fn {field, view, template} ->
14 {field, render_relationship(Map.get(struct, field), view, template)}
15 end)
16 |> Enum.into(%{})
17end
18
19defp render_relationship(%Ecto.Association.NotLoaded{}, _, _), do: nil
20
21defp render_relationship(relations, view, template) when is_list(relations) do
22 render_many(relations, view, template)
23end
24
25defp render_relationship(relation, view, template) do
26 render_one(relation, view, template)
27end
With this way you can handle both single struct and list of struct.
You can combine them all in one function and only need to pass field definition to this function
1@fields [:id, :title, :description, :content, :cover, :is_published]
2 @custom_fiels [:comment_count, :author_name]
3 @relationships [
4 {:author, MyBlogWeb.UserView, "user.json"},
5 {:comments, MyBlogWeb.CommentView, "comment.json"}
6 ]
7
8 def render("post.json", %{post: post}) do
9 render_json(post, @fields, @custom_fiels, @relationships)
10 end
11
12 def render_json(struct, fields, custom_fields \\ [], relationships \\ []) do
13 struct
14 |> Map.take(fields)
15 |> Map.merge(render_custom_fields(struct, custom_fields))
16 |> Map.merge(render_relationship(struct, relationships))
17 end
These functions are the same for every view, so let’s move these code to a helper module JsonViewHelper
1defmodule JsonViewHelper do
2 import Phoenix.View, only: [render_one: 3, render_many: 3]
3
4 def render_json(struct, view, fields, custom_fields \\ [], relationships \\ []) do
5 struct
6 |> Map.take(fields)
7 |> Map.merge(render_custom_fields(struct, view, custom_fields))
8 |> Map.merge(render_relationship(struct, relationships))
9 end
10
11 defp render_custom_fields(struct, view, fields) do
12 Enum.map(fields, fn field ->
13 {field, view.render_field(field, struct)}
14 end)
15 |> Enum.into(%{})
16 end
17
18 defp render_relationship(struct, relationships) do
19 Enum.map(relationships, fn {field, view, template} ->
20 {field, render_relationship(Map.get(struct, field), view, template)}
21 end)
22 |> Enum.into(%{})
23 end
24
25 defp render_relationship(%Ecto.Association.NotLoaded{}, _, _), do: nil
26
27 defp render_relationship(relations, view, template) when is_list(relations) do
28 render_many(relations, view, template)
29 end
30
31 defp render_relationship(relation, view, template) do
32 render_one(relation, view, template)
33 end
34end
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:
1defmodule BlogeeWeb.PostView do
2 ...
3 @fields [:id, :title, :description, :content, :cover]
4 @custom_fields [:status]
5 @relationships [
6 {:author, BlogeeWeb.UserView, "basic_info.json"},
7 {:category, BlogeeWeb.CategoryView, "category.json"}
8 ]
9 def render("post.json", %{post: post}) do
10 JsonViewHelper.render_json(post, __MODULE__, @fields, @custom_fields, @relationships)
11 end
12
13 def render_field(:status, post) do
14 if post.is_published do
15 "published"
16 else
17 "draft"
18 end
19 end
20end
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
When writing API with Phoenix and render json to client, For some fields I want to keep it original value. For some fields, I want to do some …
The story At our company, OnPoint, we are building an ecommerce website using Phoenix Framework. And I am working on admin to manage product, orders …