defmodule JSONAPI.Serializer do
@moduledoc """
Serialize a map of data into a properly formatted JSON API response object
"""
alias JSONAPI.{Config, Utils, View}
alias Plug.Conn
require Logger
@type document :: map()
@doc """
Takes a view, data and a optional plug connection and returns a fully JSONAPI Serialized document.
This assumes you are using the JSONAPI.View and have data in maps or structs.
Please refer to `JSONAPI.View` for more information. If you are in interested in relationships
and includes you may also want to reference the `JSONAPI.QueryParser`.
"""
@spec serialize(View.t(), View.data(), Conn.t() | nil, View.meta() | nil, View.options()) ::
document()
def serialize(view, data, conn \\ nil, meta \\ nil, options \\ []) do
{query_includes, query_page} =
case conn do
%Conn{assigns: %{jsonapi_query: %Config{include: include, page: page}}} ->
{include, page}
_ ->
{[], nil}
end
{to_include, encoded_data} = encode_data(view, data, conn, query_includes, options)
encoded_data = %{
data: encoded_data,
included: flatten_included(to_include)
}
encoded_data =
if is_map(meta) do
Map.put(encoded_data, :meta, meta)
else
encoded_data
end
merge_links(encoded_data, data, view, conn, query_page, remove_links?(), options)
end
def encode_data(_view, nil, _conn, _query_includes, _options), do: {[], nil}
def encode_data(view, data, conn, query_includes, options) when is_list(data) do
Enum.map_reduce(data, [], fn d, acc ->
{to_include, encoded_data} = encode_data(view, d, conn, query_includes, options)
{to_include, acc ++ [encoded_data]}
end)
end
def encode_data(view, data, conn, query_includes, options) do
valid_includes = get_includes(view, query_includes)
encoded_data = %{
id: view.id(data),
type: view.type(),
attributes: transform_fields(view.attributes(data, conn)),
relationships: %{}
}
doc = merge_links(encoded_data, data, view, conn, nil, remove_links?(), options)
doc =
case view.meta(data, conn) do
nil -> doc
meta -> Map.put(doc, :meta, meta)
end
encode_relationships(conn, doc, {view, data, query_includes, valid_includes}, options)
end
@spec encode_relationships(Conn.t(), document(), tuple(), list()) :: tuple()
def encode_relationships(conn, doc, {view, data, _, _} = view_info, options) do
view.relationships()
|> Enum.filter(&data_loaded?(Map.get(data, get_data_key(&1))))
|> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2, options))
end
defp get_data_key(rel_config), do: elem(extrapolate_relationship_config(rel_config), 1)
@spec build_relationships(Conn.t(), tuple(), term(), term(), module(), tuple(), list()) ::
tuple()
def build_relationships(
conn,
{parent_view, parent_data, query_includes, valid_includes},
relationship_name,
rel_data,
rel_view,
acc,
options
) do
# Build the relationship url
rel_key = transform_fields(relationship_name)
rel_url = parent_view.url_for_rel(parent_data, rel_key, conn)
# Build the relationship
acc =
put_in(
acc,
[:relationships, rel_key],
encode_relation({rel_view, rel_data, rel_url, conn})
)
valid_include_view = include_view(valid_includes, relationship_name)
if {rel_view, :include} == valid_include_view && data_loaded?(rel_data) do
rel_query_includes =
if is_list(query_includes) do
query_includes
|> Enum.reduce([], fn
{^relationship_name, value}, acc -> acc ++ [value]
_, acc -> acc
end)
|> List.flatten()
else
[]
end
{rel_included, encoded_rel} =
encode_data(rel_view, rel_data, conn, rel_query_includes, options)
{rel_included ++ [encoded_rel], acc}
else
{nil, acc}
end
end
@spec build_relationships(Conn.t(), tuple(), tuple(), tuple(), list()) :: tuple()
def build_relationships(
conn,
{_parent_view, data, _query_includes, _valid_includes} = parent_info,
rel_config,
acc,
options
) do
{rewrite_key, data_key, rel_view, _include} = extrapolate_relationship_config(rel_config)
rel_data = Map.get(data, data_key)
build_relationships(
conn,
parent_info,
rewrite_key,
rel_data,
rel_view,
acc,
options
)
end
@doc """
Given the relationship config entry provided by a JSONAPI.View, produce
the extrapolated config tuple containing:
- The name of the relationship to be used when serializing
- The key in the data the relationship is found under
- The relationship resource's JSONAPI.View module
- A boolean for whether the relationship is included by default or not
"""
@spec extrapolate_relationship_config(tuple()) :: {atom(), atom(), module(), boolean()}
def extrapolate_relationship_config({rewrite_key, {data_key, view, :include}}) do
{rewrite_key, data_key, view, true}
end
def extrapolate_relationship_config({data_key, {view, :include}}) do
{data_key, data_key, view, true}
end
def extrapolate_relationship_config({rewrite_key, {data_key, view}}) do
{rewrite_key, data_key, view, false}
end
def extrapolate_relationship_config({data_key, view}) do
{data_key, data_key, view, false}
end
defp include_view(valid_includes, key) when is_list(valid_includes) do
valid_includes
|> Keyword.get(key)
|> generate_view_tuple
end
defp include_view(view, _key), do: generate_view_tuple(view)
defp generate_view_tuple({_rewrite_key, view, :include}), do: {view, :include}
defp generate_view_tuple({view, :include}), do: {view, :include}
defp generate_view_tuple({_rewrite_key, view}), do: {view, :include}
defp generate_view_tuple(view) when is_atom(view), do: {view, :include}
@spec data_loaded?(map() | list()) :: boolean()
def data_loaded?(rel_data) do
assoc_loaded?(rel_data) && (is_map(rel_data) || is_list(rel_data))
end
@spec encode_relation(tuple()) :: map()
def encode_relation({rel_view, rel_data, _rel_url, _conn} = info) do
data = %{
data: encode_rel_data(rel_view, rel_data)
}
merge_related_links(data, info, remove_links?())
end
defp merge_base_links(%{links: links} = doc, data, view, conn) do
view_links = Map.merge(view.links(data, conn), links)
Map.merge(doc, %{links: view_links})
end
defp merge_links(doc, data, view, conn, page, false, options) when is_list(data) do
links =
Map.merge(view.pagination_links(data, conn, page, options), %{
self: view.url_for_pagination(data, conn, page)
})
doc
|> Map.merge(%{links: links})
|> merge_base_links(data, view, conn)
end
defp merge_links(doc, data, view, conn, _page, false, _options) do
doc
|> Map.merge(%{links: %{self: view.url_for(data, conn)}})
|> merge_base_links(data, view, conn)
end
defp merge_links(doc, _data, _view, _conn, _page, _remove_links, _options), do: doc
defp merge_related_links(
encoded_data,
{rel_view, rel_data, rel_url, conn},
false = _remove_links
) do
Map.merge(encoded_data, %{links: %{self: rel_url, related: rel_view.url_for(rel_data, conn)}})
end
defp merge_related_links(encoded_rel_data, _info, _remove_links), do: encoded_rel_data
@spec encode_rel_data(module(), map() | list()) :: map() | nil
def encode_rel_data(_view, nil), do: nil
def encode_rel_data(view, data) when is_list(data) do
Enum.map(data, &encode_rel_data(view, &1))
end
def encode_rel_data(view, data) do
%{
type: view.type(),
id: view.id(data)
}
end
# Flatten and unique all the included objects
@spec flatten_included(keyword()) :: keyword()
def flatten_included(included) do
included
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
end
defp assoc_loaded?(nil), do: false
defp assoc_loaded?(%{__struct__: Ecto.Association.NotLoaded}), do: false
defp assoc_loaded?(_association), do: true
defp get_includes(view, query_includes) do
includes = get_default_includes(view) ++ get_query_includes(view, query_includes)
Enum.uniq(includes)
end
defp get_default_includes(view) do
rels = view.relationships()
Enum.filter(rels, &include_rel_by_default/1)
end
defp include_rel_by_default(rel_config) do
{_rel_key, _data_key, _view, include_by_default} = extrapolate_relationship_config(rel_config)
include_by_default
end
defp get_query_includes(view, query_includes) do
rels = view.relationships()
query_includes
|> Enum.map(fn
{include, _} -> Keyword.take(rels, [include])
include -> Keyword.take(rels, [include])
end)
|> List.flatten()
end
defp remove_links?, do: Application.get_env(:jsonapi, :remove_links, false)
defp transform_fields(fields) do
case Utils.String.field_transformation() do
:camelize -> Utils.String.expand_fields(fields, &Utils.String.camelize/1)
:dasherize -> Utils.String.expand_fields(fields, &Utils.String.dasherize/1)
_ -> fields
end
end
end