diff --git a/lib/jsonapi_plug.ex b/lib/jsonapi_plug.ex index 894fb47..2122bc5 100644 --- a/lib/jsonapi_plug.ex +++ b/lib/jsonapi_plug.ex @@ -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]} ] ] diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 2324227..ef5e61e 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -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 @@ -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( diff --git a/lib/jsonapi_plug/resource.ex b/lib/jsonapi_plug/resource.ex index b41eb65..6d82991 100644 --- a/lib/jsonapi_plug/resource.ex +++ b/lib/jsonapi_plug/resource.ex @@ -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 diff --git a/test/jsonapi_plug/composed_attributes_test.exs b/test/jsonapi_plug/composed_attributes_test.exs new file mode 100644 index 0000000..0cc625f --- /dev/null +++ b/test/jsonapi_plug/composed_attributes_test.exs @@ -0,0 +1,313 @@ +defmodule JSONAPIPlug.ComposedAttributesTest do + use ExUnit.Case + + import Plug.Conn + import Plug.Test + + alias JSONAPIPlug.TestSupport.Plugs.ComposedNameUserPlug + alias JSONAPIPlug.TestSupport.Resources.{ComposedCamelUser, ComposedNameUser} + alias Plug.Conn + + # Plug for camelCase recase testing (DefaultAPI uses :camelize by default) + defmodule ComposedCamelUserRenderPlug do + use Plug.Builder + + plug Plug.Parsers, parsers: [:json], pass: ["text/*"], json_decoder: Jason + + plug JSONAPIPlug.Plug, + api: JSONAPIPlug.TestSupport.API.DefaultAPI, + path: "composed-camel-users", + resource: JSONAPIPlug.TestSupport.Resources.ComposedCamelUser + + plug :passthrough + + defp passthrough(conn, _) do + resp = + JSONAPIPlug.render(conn, conn.assigns[:data], conn.assigns[:meta]) + |> Jason.encode!() + + send_resp(conn, 200, resp) + end + end + + defmodule ComposedCamelUserPlug do + use Plug.Builder + + plug Plug.Parsers, parsers: [:json], json_decoder: Jason + + plug JSONAPIPlug.Plug, + api: JSONAPIPlug.TestSupport.API.DefaultAPI, + path: "composed-camel-users", + resource: JSONAPIPlug.TestSupport.Resources.ComposedCamelUser + end + + # A plug that also renders the response, so we can test serialization end-to-end + defmodule ComposedNameUserRenderPlug do + use Plug.Builder + + plug Plug.Parsers, parsers: [:json], pass: ["text/*"], json_decoder: Jason + + plug JSONAPIPlug.Plug, + api: JSONAPIPlug.TestSupport.API.DefaultAPI, + path: "composed-name-users", + resource: JSONAPIPlug.TestSupport.Resources.ComposedNameUser + + plug :passthrough + + defp passthrough(conn, _) do + resp = + JSONAPIPlug.render(conn, conn.assigns[:data], conn.assigns[:meta]) + |> Jason.encode!() + + send_resp(conn, 200, resp) + end + end + + describe "serialization (struct → JSON API)" do + test "expands composed field into derived fields" do + conn = + conn(:get, "/composed-name-users") + |> assign(:data, [%ComposedNameUser{id: 1, username: "mrossi", full_name: "Mario/Rossi"}]) + |> ComposedNameUserRenderPlug.call([]) + + assert %{ + "data" => [ + %{ + "id" => "1", + "type" => "composed-name-user", + "attributes" => attributes + } + ] + } = Jason.decode!(conn.resp_body) + + assert attributes["nome"] == "Mario" + assert attributes["cognome"] == "Rossi" + refute Map.has_key?(attributes, "full_name"), "real field must not appear in JSON API" + end + + test "includes normal attributes alongside composed ones" do + conn = + conn(:get, "/composed-name-users") + |> assign(:data, [%ComposedNameUser{id: 1, username: "mrossi", full_name: "Mario/Rossi"}]) + |> ComposedNameUserRenderPlug.call([]) + + assert %{ + "data" => [%{"attributes" => attributes}] + } = Jason.decode!(conn.resp_body) + + assert attributes["username"] == "mrossi" + assert attributes["nome"] == "Mario" + assert attributes["cognome"] == "Rossi" + end + + test "handles nil composed field value" do + conn = + conn(:get, "/composed-name-users") + |> assign(:data, [%ComposedNameUser{id: 1, username: "mrossi", full_name: nil}]) + |> ComposedNameUserRenderPlug.call([]) + + assert %{ + "data" => [%{"attributes" => attributes}] + } = Jason.decode!(conn.resp_body) + + assert is_nil(attributes["nome"]) + assert is_nil(attributes["cognome"]) + end + end + + describe "deserialization (JSON API → params)" do + test "composes derived fields into the real field" do + assert %Conn{private: %{jsonapi_plug: %JSONAPIPlug{params: params}}} = + conn( + :post, + "/", + Jason.encode!(%{ + "data" => %{ + "type" => "composed-name-user", + "attributes" => %{ + "username" => "mrossi", + "nome" => "Mario", + "cognome" => "Rossi" + } + } + }) + ) + |> put_req_header("content-type", JSONAPIPlug.mime_type()) + |> put_req_header("accept", JSONAPIPlug.mime_type()) + |> ComposedNameUserPlug.call([]) + + assert params["full_name"] == "Mario/Rossi" + assert params["username"] == "mrossi" + end + + test "does not include derived fields as independent params" do + assert %Conn{private: %{jsonapi_plug: %JSONAPIPlug{params: params}}} = + conn( + :post, + "/", + Jason.encode!(%{ + "data" => %{ + "type" => "composed-name-user", + "attributes" => %{ + "nome" => "Mario", + "cognome" => "Rossi" + } + } + }) + ) + |> put_req_header("content-type", JSONAPIPlug.mime_type()) + |> put_req_header("accept", JSONAPIPlug.mime_type()) + |> ComposedNameUserPlug.call([]) + + refute Map.has_key?(params, "nome"), "derived field must not appear as independent param" + refute Map.has_key?(params, "cognome"), "derived field must not appear as independent param" + end + + test "does not call deserialize when no derived fields are present" do + assert %Conn{private: %{jsonapi_plug: %JSONAPIPlug{params: params}}} = + conn( + :post, + "/", + Jason.encode!(%{ + "data" => %{ + "type" => "composed-name-user", + "attributes" => %{ + "username" => "mrossi" + } + } + }) + ) + |> put_req_header("content-type", JSONAPIPlug.mime_type()) + |> put_req_header("accept", JSONAPIPlug.mime_type()) + |> ComposedNameUserPlug.call([]) + + refute Map.has_key?(params, "full_name"), + "real field must not appear in params when no derived fields are present" + + assert params["username"] == "mrossi" + end + + test "calls deserialize with partial derived fields map" do + assert %Conn{private: %{jsonapi_plug: %JSONAPIPlug{params: params}}} = + conn( + :post, + "/", + Jason.encode!(%{ + "data" => %{ + "type" => "composed-name-user", + "attributes" => %{ + "nome" => "Mario" + } + } + }) + ) + |> put_req_header("content-type", JSONAPIPlug.mime_type()) + |> put_req_header("accept", JSONAPIPlug.mime_type()) + |> ComposedNameUserPlug.call([]) + + # deserialize is called with partial map %{nome: "Mario"}, produces "Mario/" (cognome is nil) + assert params["full_name"] == "Mario/" + end + end + + describe "recase of derived fields (camelize)" do + test "serialization recases derived field keys to camelCase" do + conn = + conn(:get, "/composed-camel-users") + |> assign(:data, [%ComposedCamelUser{id: 1, full_name: "Mario Rossi"}]) + |> ComposedCamelUserRenderPlug.call([]) + + assert %{"data" => [%{"attributes" => attributes}]} = Jason.decode!(conn.resp_body) + + assert attributes["firstName"] == "Mario" + assert attributes["lastName"] == "Rossi" + refute Map.has_key?(attributes, "first_name") + refute Map.has_key?(attributes, "last_name") + refute Map.has_key?(attributes, "full_name") + end + + test "deserialization accepts camelCase derived field keys from client" do + assert %Conn{private: %{jsonapi_plug: %JSONAPIPlug{params: params}}} = + conn( + :post, + "/", + Jason.encode!(%{ + "data" => %{ + "type" => "composed-camel-user", + "attributes" => %{ + "firstName" => "Mario", + "lastName" => "Rossi" + } + } + }) + ) + |> put_req_header("content-type", JSONAPIPlug.mime_type()) + |> put_req_header("accept", JSONAPIPlug.mime_type()) + |> ComposedCamelUserPlug.call([]) + + assert params["full_name"] == "Mario Rossi" + refute Map.has_key?(params, "firstName") + refute Map.has_key?(params, "lastName") + refute Map.has_key?(params, "first_name") + refute Map.has_key?(params, "last_name") + end + end + + describe "compile-time validation" do + test "raises if type: :composed is declared without composed_of" do + assert_raise RuntimeError, ~r/composed_of/, fn -> + defmodule InvalidComposedNoCF do + @derive { + JSONAPIPlug.Resource, + type: "invalid", + attributes: [ + full_name: [type: :composed] + ] + } + + defstruct id: nil, full_name: nil + end + end + end + + test "raises if type: :composed is used with serialize: false" do + assert_raise RuntimeError, ~r/serialize/, fn -> + defmodule InvalidComposedSerializeFalse do + @derive { + JSONAPIPlug.Resource, + type: "invalid", + attributes: [ + full_name: [ + type: :composed, + composed_of: [:nome, :cognome], + serialize: false + ] + ] + } + + defstruct id: nil, full_name: nil + end + end + end + + test "raises if type: :composed is used with deserialize: false" do + assert_raise RuntimeError, ~r/deserialize/, fn -> + defmodule InvalidComposedDeserializeFalse do + @derive { + JSONAPIPlug.Resource, + type: "invalid", + attributes: [ + full_name: [ + type: :composed, + composed_of: [:nome, :cognome], + deserialize: false + ] + ] + } + + defstruct id: nil, full_name: nil + end + end + end + end +end diff --git a/test/support/plugs.ex b/test/support/plugs.ex index 36e944d..2e32171 100644 --- a/test/support/plugs.ex +++ b/test/support/plugs.ex @@ -11,7 +11,20 @@ defmodule JSONAPIPlug.TestSupport.Plugs do UnderscoringAPI } - alias JSONAPIPlug.TestSupport.Resources.{Car, Post, User} + alias JSONAPIPlug.TestSupport.Resources.{Car, ComposedNameUser, Post, User} + + defmodule ComposedNameUserPlug do + @moduledoc false + + use Plug.Builder + + plug Plug.Parsers, parsers: [:json], json_decoder: Jason + + plug JSONAPIPlug.Plug, + api: DefaultAPI, + path: "composed-name-users", + resource: ComposedNameUser + end defmodule CarResourcePlug do @moduledoc false diff --git a/test/support/resources.ex b/test/support/resources.ex index aebd5cb..0625c47 100644 --- a/test/support/resources.ex +++ b/test/support/resources.ex @@ -138,3 +138,84 @@ defimpl JSONAPIPlug.Resource.Attribute, for: JSONAPIPlug.TestSupport.Resources.U def serialize(_resource, _field_name, value, _conn), do: value def deserialize(_resource, _field_name, value, _conn), do: value end + +defmodule JSONAPIPlug.TestSupport.Resources.ComposedNameUser do + @moduledoc false + + @derive { + JSONAPIPlug.Resource, + type: "composed-name-user", + attributes: [ + username: [], + full_name: [ + type: :composed, + composed_of: [:nome, :cognome] + ] + ] + } + + defstruct id: nil, username: nil, full_name: nil +end + +defimpl JSONAPIPlug.Resource.Attribute, + for: JSONAPIPlug.TestSupport.Resources.ComposedNameUser do + def serialize(_resource, :full_name, nil, _conn), + do: %{nome: nil, cognome: nil} + + def serialize(_resource, :full_name, full_name, _conn) do + case String.split(full_name, "/", parts: 2) do + [nome, cognome] -> %{nome: nome, cognome: cognome} + [nome] -> %{nome: nome, cognome: nil} + end + end + + def serialize(_resource, _attribute, value, _conn), do: value + + def deserialize(_resource, :full_name, derived_fields, _conn) do + nome = Map.get(derived_fields, :nome) + cognome = Map.get(derived_fields, :cognome) + "#{nome}/#{cognome}" + end + + def deserialize(_resource, _attribute, value, _conn), do: value +end + +defmodule JSONAPIPlug.TestSupport.Resources.ComposedCamelUser do + @moduledoc false + + @derive { + JSONAPIPlug.Resource, + type: "composed-camel-user", + attributes: [ + full_name: [ + type: :composed, + composed_of: [:first_name, :last_name] + ] + ] + } + + defstruct id: nil, full_name: nil +end + +defimpl JSONAPIPlug.Resource.Attribute, + for: JSONAPIPlug.TestSupport.Resources.ComposedCamelUser do + def serialize(_resource, :full_name, nil, _conn), + do: %{first_name: nil, last_name: nil} + + def serialize(_resource, :full_name, full_name, _conn) do + case String.split(full_name, " ", parts: 2) do + [first, last] -> %{first_name: first, last_name: last} + [first] -> %{first_name: first, last_name: nil} + end + end + + def serialize(_resource, _attribute, value, _conn), do: value + + def deserialize(_resource, :full_name, derived_fields, _conn) do + first = Map.get(derived_fields, :first_name) + last = Map.get(derived_fields, :last_name) + "#{first} #{last}" + end + + def deserialize(_resource, _attribute, value, _conn), do: value +end