Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lib/jsonapi_plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,19 @@ defmodule JSONAPIPlug do
doc: "Controls wether the attribute is deserialized in requests.",
type: :boolean,
default: true
],
composed_of: [
doc:
"List of derived field names for a composed attribute. " <>
"Required when `type: :composed`. The transformation logic must be implemented via " <>
"`defimpl JSONAPIPlug.Resource.Attribute`: `serialize/4` SHALL return a map " <>
"`%{derived_field => value}` and `deserialize/4` SHALL return the value of the " <>
"real field directly.",
type: {:list, :atom}
],
type: [
doc: "Attribute type. Use `:composed` for attributes composed of multiple derived fields.",
type: {:in, [:composed]}
]
]

Expand Down
153 changes: 125 additions & 28 deletions lib/jsonapi_plug/normalizer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -153,25 +153,88 @@ defmodule JSONAPIPlug.Normalizer do
attribute,
%Conn{private: %{jsonapi_plug: %JSONAPIPlug{} = jsonapi_plug}} = conn
) do
case Resource.field_option(resource, attribute, :deserialize) do
false ->
case {Resource.field_option(resource, attribute, :type),
Resource.field_option(resource, attribute, :deserialize)} do
{:composed, _deserialize} ->
denormalize_composed_attribute(
params,
resource_object,
resource,
attribute,
conn,
jsonapi_plug
)

{_type, false} ->
params

_deserialize ->
case Map.fetch(
resource_object.attributes,
Resource.recase_field(resource, attribute, jsonapi_plug.config[:case])
) do
{:ok, value} ->
jsonapi_plug.config[:normalizer].denormalize_attribute(
params,
Resource.field_option(resource, attribute, :name) || attribute,
Attribute.deserialize(resource, attribute, value, conn)
)
{_type, _deserialize} ->
denormalize_simple_attribute(
params,
resource_object,
resource,
attribute,
conn,
jsonapi_plug
)
end
end

defp denormalize_composed_attribute(
params,
resource_object,
resource,
attribute,
conn,
jsonapi_plug
) do
composed_of = Resource.field_option(resource, attribute, :composed_of) || []
derived_fields_map = collect_derived_fields(composed_of, resource_object, jsonapi_plug)

if map_size(derived_fields_map) > 0 do
value = Attribute.deserialize(resource, attribute, derived_fields_map, conn)
key = Resource.field_option(resource, attribute, :name) || attribute
Map.put(params, to_string(key), value)
else
params
end
end

defp collect_derived_fields(composed_of, resource_object, jsonapi_plug) do
Enum.reduce(composed_of, %{}, fn derived_field, acc ->
recased_key =
derived_field
|> to_string()
|> JSONAPIPlug.recase(jsonapi_plug.config[:case])

case Map.fetch(resource_object.attributes, recased_key) do
{:ok, value} -> Map.put(acc, derived_field, value)
:error -> acc
end
end)
end

defp denormalize_simple_attribute(
params,
resource_object,
resource,
attribute,
conn,
jsonapi_plug
) do
case Map.fetch(
resource_object.attributes,
Resource.recase_field(resource, attribute, jsonapi_plug.config[:case])
) do
{:ok, value} ->
jsonapi_plug.config[:normalizer].denormalize_attribute(
params,
Resource.field_option(resource, attribute, :name) || attribute,
Attribute.deserialize(resource, attribute, value, conn)
)

:error ->
params
end
:error ->
params
end
end

Expand Down Expand Up @@ -339,20 +402,54 @@ defmodule JSONAPIPlug.Normalizer do
Resource.attributes(resource)
|> requested_fields(resource, conn)
|> Enum.reduce(%{}, fn attribute, attributes ->
case Resource.field_option(resource, attribute, :serialize) do
false ->
attributes
normalize_attribute(attributes, resource, attribute, conn, jsonapi_plug)
end)
end

_serialize ->
key = Resource.field_option(resource, attribute, :name) || attribute
defp normalize_attribute(attributes, resource, attribute, conn, jsonapi_plug) do
case {Resource.field_option(resource, attribute, :type),
Resource.field_option(resource, attribute, :serialize)} do
{:composed, _serialize} ->
normalize_composed_attribute(attributes, resource, attribute, conn, jsonapi_plug)

Map.put(
attributes,
Resource.recase_field(resource, attribute, jsonapi_plug.config[:case]),
Attribute.serialize(resource, attribute, Map.get(resource, key), conn)
)
end
end)
{_type, false} ->
attributes

{_type, _serialize} ->
normalize_simple_attribute(attributes, resource, attribute, conn, jsonapi_plug)
end
end

defp normalize_composed_attribute(attributes, resource, attribute, conn, jsonapi_plug) do
key = Resource.field_option(resource, attribute, :name) || attribute
value = Map.get(resource, key)

case Attribute.serialize(resource, attribute, value, conn) do
values_map when is_map(values_map) ->
Enum.reduce(values_map, attributes, fn {derived_field, derived_value}, acc ->
recased_key =
derived_field
|> to_string()
|> JSONAPIPlug.recase(jsonapi_plug.config[:case])

Map.put(acc, recased_key, derived_value)
end)

other ->
raise "#{inspect(Resource.type(resource))}: Attribute.serialize/4 for composed " <>
"attribute #{inspect(attribute)} must return a map of derived fields, " <>
"got: #{inspect(other)}"
end
end

defp normalize_simple_attribute(attributes, resource, attribute, conn, jsonapi_plug) do
key = Resource.field_option(resource, attribute, :name) || attribute

Map.put(
attributes,
Resource.recase_field(resource, attribute, jsonapi_plug.config[:case]),
Attribute.serialize(resource, attribute, Map.get(resource, key), conn)
)
end

defp normalize_relationships(
Expand Down
25 changes: 25 additions & 0 deletions lib/jsonapi_plug/resource.ex
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,31 @@ defimpl JSONAPIPlug.Resource, for: Any do
raise "Illegal relationship name '#{field_name}' for resource '#{options[:type]}'. See https://jsonapi.org/format/#document-resource-object-fields for more information."
end
end

for {field_name, field_options} <- options[:attributes] || [],
is_list(field_options) do
check_composed_attribute(field_name, field_options, options[:type])
end
end

defp check_composed_attribute(field_name, field_options, resource_type) do
case Keyword.get(field_options, :type) do
:composed ->
unless is_list(Keyword.get(field_options, :composed_of)) do
raise "Composed attribute '#{field_name}' on resource '#{resource_type}' requires `composed_of: [...]` option."
end

if Keyword.get(field_options, :serialize) == false do
raise "Composed attribute '#{field_name}' on resource '#{resource_type}' cannot use `serialize: false`. Implement `defimpl JSONAPIPlug.Resource.Attribute` to control serialization."
end

if Keyword.get(field_options, :deserialize) == false do
raise "Composed attribute '#{field_name}' on resource '#{resource_type}' cannot use `deserialize: false`. Implement `defimpl JSONAPIPlug.Resource.Attribute` to control deserialization."
end

_ ->
:ok
end
end

defp generate_field_option(options) do
Expand Down
Loading