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