defmodule JSONAPIPlug.Normalizer do
@moduledoc """
Transforms user data to and from a `JSON:API` Document.
The default implementation transforms `JSON:API`documents in requests to an ecto
friendly format and expects `Ecto.Schema` instances when rendering data in responses.
The data it produces is stored under the `:params` key of the `JSONAPIPlug` struct
that will be stored in the `Plug.Conn` private assign `:jsonapi_plug`.
You can customize normalization to convert your application data to and from
the `JSONAPIPlug.Document` data structure by providing an implementation of
the `JSONAPIPlug.Normalizer` behaviour.
```elixir
defmodule MyApp.API.Normalizer
...
@behaviour JSONAPIPlug.Normalizer
...
end
```
and by configuring it in your api configuration:
```elixir
config :my_app, MyApp.API, normalizer: MyApp.API.Normalizer
```
You can return an error during parsing by raising `JSONAPIPlug.Exceptions.InvalidDocument` at
any point in your normalizer code.
"""
alias JSONAPIPlug.{
API,
Document,
Document.RelationshipObject,
Document.ResourceIdentifierObject,
Document.ResourceObject,
Exceptions.InvalidDocument,
Pagination,
Resource
}
alias Plug.Conn
@type t :: module()
@type params :: term()
@type value :: term()
@callback resource_params :: params() | no_return()
@callback denormalize_attribute(
params(),
Resource.field_name(),
term()
) ::
params() | no_return()
@callback denormalize_relationship(
params(),
RelationshipObject.t() | [RelationshipObject.t()],
Resource.field_name(),
term()
) ::
params() | no_return()
@callback normalize_attribute(params(), Resource.field_name()) :: value() | no_return()
@doc "Transforms a JSON:API Document user data"
@spec denormalize(Document.t(), Resource.t(), Conn.t()) :: Conn.params() | no_return()
def denormalize(%Document{data: nil}, _resource, _conn), do: %{}
def denormalize(%Document{data: resource_objects} = document, resource, conn)
when is_list(resource_objects),
do: Enum.map(resource_objects, &denormalize_resource(document, &1, resource, conn))
def denormalize(
%Document{data: %ResourceObject{} = resource_object} = document,
resource,
conn
),
do: denormalize_resource(document, resource_object, resource, conn)
defp denormalize_resource(
document,
%ResourceObject{} = resource_object,
resource,
%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = conn
) do
normalizer = resource.normalizer() || API.get_config(jsonapi_plug.api, [:normalizer])
normalizer.resource_params()
|> denormalize_id(resource_object, resource, conn, normalizer)
|> denormalize_attributes(resource_object, resource, conn, normalizer)
|> denormalize_relationships(resource_object, document, resource, conn, normalizer)
end
defp denormalize_id(
params,
%ResourceObject{id: nil},
_resource,
%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}},
_normalizer
) do
if API.get_config(jsonapi_plug.api, [:client_generated_ids], false) do
raise InvalidDocument,
message: "Resource ID not received in request and API requires Client-Generated IDs",
reference: "https://jsonapi.org/format/1.0/#crud-creating-client-ids"
end
params
end
defp denormalize_id(params, %ResourceObject{} = resource_object, resource, _conn, normalizer),
do: normalizer.denormalize_attribute(params, resource.id_attribute(), resource_object.id)
defp denormalize_attributes(
params,
%ResourceObject{} = resource_object,
resource,
conn,
normalizer
) do
Enum.reduce(resource.attributes(), params, fn attribute, params ->
name = Resource.field_name(attribute)
deserialize = Resource.field_option(attribute, :deserialize)
key = to_string(Resource.field_option(attribute, :name) || name)
case Map.fetch(resource_object.attributes, recase_field(conn, name)) do
{:ok, _value} when deserialize == false ->
params
{:ok, value} when is_function(deserialize, 2) ->
normalizer.denormalize_attribute(params, key, deserialize.(value, conn))
{:ok, value} ->
normalizer.denormalize_attribute(params, key, value)
:error ->
params
end
end)
end
defp denormalize_relationships(
params,
%ResourceObject{relationships: relationships},
%Document{} = document,
resource,
conn,
normalizer
) do
Enum.reduce(resource.relationships(), params, fn relationship, params ->
name = Resource.field_name(relationship)
key = to_string(Resource.field_option(relationship, :name) || name)
related_resource = Resource.field_option(relationship, :resource)
related_many = Resource.field_option(relationship, :many)
related_relationships = Map.get(relationships, to_string(name))
case {related_many, related_relationships} do
{_many, nil} ->
params
{true, related_relationships} when is_list(related_relationships) ->
value =
Enum.map(
related_relationships,
&find_related_relationship(document, &1, related_resource, conn)
)
normalizer.denormalize_relationship(params, related_relationships, key, value)
{_many, related_relationships} when is_list(related_relationships) ->
raise InvalidDocument,
message: "List of resources for one-to-one relationship during normalization",
reference: nil
{true, _related_data} ->
raise InvalidDocument,
message: "Single resource for many relationship during normalization",
reference: nil
{_many, %RelationshipObject{data: nil}} ->
Map.put(params, key <> "_id", nil)
{_many, related_relationship} ->
value =
find_related_relationship(
document,
related_relationship,
related_resource,
conn
)
normalizer.denormalize_relationship(params, related_relationship, key, value)
end
end)
end
defp find_related_relationship(
%Document{} = document,
%RelationshipObject{
data: %ResourceIdentifierObject{
id: id,
type: type
}
},
resource,
conn
) do
Enum.find_value(document.included || [], fn
%ResourceObject{id: ^id, type: ^type} = resource_object ->
denormalize_resource(document, resource_object, resource, conn)
%ResourceObject{} ->
nil
end)
end
@doc "Transforms user data into a JSON:API Document"
@spec normalize(
Resource.t(),
Conn.t() | nil,
Resource.data() | nil,
Resource.meta() | nil,
Resource.options()
) ::
Document.t() | no_return()
def normalize(resource, conn, data, meta, options) do
%Document{meta: meta}
|> normalize_data(resource, conn, data, options)
|> normalize_links(resource, conn, data, options)
|> normalize_included(resource, conn, data, options)
|> included_to_list()
end
defp included_to_list(%Document{included: nil} = document), do: document
defp included_to_list(%Document{included: included} = document),
do: %Document{document | included: MapSet.to_list(included)}
defp normalize_data(document, _resource, _conn, nil = _data, _options),
do: document
defp normalize_data(document, resource, conn, data, options) when is_list(data) do
%Document{document | data: Enum.map(data, &normalize_resource(resource, conn, &1, options))}
end
defp normalize_data(document, resource, conn, data, options) do
%Document{document | data: normalize_resource(resource, conn, data, options)}
end
defp normalize_resource(
resource,
%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = conn,
data,
options
) do
normalizer = resource.normalizer() || API.get_config(jsonapi_plug.api, [:normalizer])
%ResourceObject{}
|> normalize_id(resource, conn, data, options, normalizer)
|> normalize_type(resource, conn, data, options)
|> normalize_attributes(resource, conn, data, options, normalizer)
|> normalize_relationships(resource, conn, data, options, normalizer)
end
defp normalize_id(resource_object, resource, _conn, data, _options, normalizer),
do: %ResourceObject{
resource_object
| id: to_string(normalizer.normalize_attribute(data, resource.id_attribute()))
}
defp normalize_type(resource_object, resource, _conn, _data, _options),
do: %ResourceObject{resource_object | type: resource.type()}
defp normalize_attributes(resource_object, resource, conn, data, _options, normalizer) do
%ResourceObject{
resource_object
| attributes:
resource.attributes()
|> requested_fields(resource, conn)
|> Enum.reduce(%{}, fn attribute, attributes ->
name = Resource.field_name(attribute)
key = Resource.field_option(attribute, :name) || Resource.field_name(attribute)
case Resource.field_option(attribute, :serialize) do
false ->
attributes
serialize when serialize in [true, nil] ->
value = normalizer.normalize_attribute(data, key)
Map.put(attributes, recase_field(conn, name), value)
serialize when is_function(serialize, 2) ->
value = serialize.(data, conn)
Map.put(attributes, recase_field(conn, name), value)
end
end)
}
end
defp normalize_relationships(resource_object, resource, conn, data, _options, normalizer) do
%ResourceObject{
resource_object
| relationships:
resource.relationships()
|> Enum.filter(&relationship_loaded?(Map.get(data, elem(&1, 0))))
|> Enum.into(%{}, fn relationship ->
name = Resource.field_name(relationship)
key = Resource.field_option(relationship, :name) || Resource.field_name(relationship)
related_data = Map.get(data, key)
related_resource = Resource.field_option(relationship, :resource)
related_many = Resource.field_option(relationship, :many)
case {related_many, related_data} do
{false, related_data} when is_list(related_data) ->
raise InvalidDocument,
message: "List of resources given to render for one-to-one relationship",
reference: nil
{true, _related_data} when not is_list(related_data) ->
raise InvalidDocument,
message: "Single resource given to render for many relationship",
reference: nil
{_related_many, related_data} ->
{
recase_field(conn, name),
%RelationshipObject{
data:
normalize_relationship(related_resource, conn, related_data, normalizer),
links: %{
self: Resource.url_for_relationship(resource, data, conn, resource.type())
},
meta: resource.meta(data, conn)
}
}
end
end)
}
end
defp normalize_relationship(resource, conn, data, normalizer) when is_list(data),
do: Enum.map(data, &normalize_relationship(resource, conn, &1, normalizer))
defp normalize_relationship(resource, conn, data, normalizer) do
%ResourceIdentifierObject{
id: to_string(normalizer.normalize_attribute(data, resource.id_attribute())),
type: resource.type(),
meta: resource.meta(data, conn)
}
end
defp normalize_links(
%Document{} = document,
resource,
%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = conn,
data,
options
)
when is_list(data) do
links =
data
|> resource.links(conn)
|> Map.merge(pagination_links(resource, conn, data, jsonapi_plug.page, options))
|> Map.merge(%{self: Pagination.url_for(resource, data, conn, jsonapi_plug.page)})
%Document{document | links: links}
end
defp normalize_links(%Document{} = document, resource, conn, data, _options) do
links =
data
|> resource.links(conn)
|> Map.merge(%{self: Resource.url_for(resource, data, conn)})
%Document{document | links: links}
end
defp pagination_links(
resource,
%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = conn,
resources,
page,
options
) do
if pagination = API.get_config(jsonapi_plug.api, [:pagination]) do
pagination.paginate(resource, resources, conn, page, options)
else
%{}
end
end
defp pagination_links(_resource, _resources, _conn, _page, _options), do: %{}
defp normalize_included(%Document{data: nil} = document, _resource, _conn, _data, _options),
do: document
defp normalize_included(
document,
resource,
%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = conn,
data,
options
) do
resource.relationships()
|> Enum.filter(&get_in(jsonapi_plug.include, [elem(&1, 0)]))
|> Enum.reduce(
document,
&normalize_resource_included(&2, resource, conn, data, options, &1)
)
end
defp normalize_included(document, _resource, _conn, _data, _options), do: document
defp normalize_resource_included(document, resource, conn, data, options, relationship)
when is_list(data) do
Enum.reduce(
data,
document,
&normalize_resource_included(&2, resource, conn, &1, options, relationship)
)
end
defp normalize_resource_included(
%Document{} = document,
_resource,
conn,
data,
options,
relationship
) do
name = Resource.field_name(relationship)
related_data = Map.get(data, name)
related_loaded? = relationship_loaded?(related_data)
related_resource = Resource.field_option(relationship, :resource)
related_many = Resource.field_option(relationship, :many)
included =
case {related_loaded?, related_many, related_data} do
{true, true, related_data} when is_list(related_data) ->
MapSet.union(
document.included || MapSet.new(),
MapSet.new(
Enum.map(
related_data,
&normalize_resource(related_resource, conn, &1, options)
)
)
)
{true, _related_many, related_data} when is_list(related_data) ->
raise InvalidDocument,
message: "List of resources given to render for one-to-one relationship",
reference: nil
{true, true, _related_data} ->
raise InvalidDocument,
message: "Single resource given to render for many relationship",
reference: nil
{true, _related_many, related_data} ->
MapSet.put(
document.included || MapSet.new(),
normalize_resource(related_resource, conn, related_data, options)
)
{false, _related_many, _related_data} ->
document.included
end
normalize_included(
%Document{document | included: included},
related_resource,
%Conn{
conn
| private: %{
conn.private
| jsonapi_plug: %JSONAPIPlug{
conn.private.jsonapi_plug
| include: get_in(conn.private.jsonapi_plug.include, [name])
}
}
},
related_data,
options
)
end
defp recase_field(%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}}, field),
do: JSONAPIPlug.recase(field, API.get_config(jsonapi_plug.api, [:case], :camelize))
defp recase_field(_conn, field),
do: JSONAPIPlug.recase(field, :camelize)
defp relationship_loaded?(nil), do: false
defp relationship_loaded?(%{__struct__: Ecto.Association.NotLoaded}), do: false
defp relationship_loaded?(_value), do: true
defp requested_fields(attributes, resource, %Conn{
private: %{jsonapi_plug: %JSONAPIPlug{fields: fields}}
})
when is_map(fields) do
case fields[resource.type()] do
nil ->
attributes
fields when is_list(fields) ->
Enum.filter(attributes, fn attribute -> Resource.field_name(attribute) in fields end)
end
end
defp requested_fields(attributes, _resource, _conn), do: attributes
end