diff --git a/lib/jsonapi/error_view.ex b/lib/jsonapi/error_view.ex index bfeeb626..e76fe41e 100644 --- a/lib/jsonapi/error_view.ex +++ b/lib/jsonapi/error_view.ex @@ -5,8 +5,11 @@ defmodule JSONAPI.ErrorView do import Plug.Conn, only: [send_resp: 3, halt: 1, put_resp_content_type: 2] @crud_message "Check out http://jsonapi.org/format/#crud for more info." + @relationship_resource_linkage_message "Check out https://jsonapi.org/format/#document-resource-object-linkage for more info." - @spec build_error(binary(), pos_integer(), binary() | nil, binary() | nil) :: map() + @type error_attrs :: map() + + @spec build_error(binary(), pos_integer(), binary() | nil, binary() | nil) :: error_attrs() def build_error(title, status, detail, pointer \\ nil, meta \\ nil) do error = %{ detail: detail, @@ -93,12 +96,57 @@ defmodule JSONAPI.ErrorView do |> serialize_error end + @spec relationships_missing_object :: map() + def relationships_missing_object do + "Relationships parameter is not an object" + |> build_error( + 400, + "Check out https://jsonapi.org/format/#document-resource-object-relationships for more info.", + "/data/relationships" + ) + |> serialize_error + end + + @spec missing_relationship_data_param_error_attrs(binary()) :: error_attrs() + def missing_relationship_data_param_error_attrs(relationship_name) do + "Missing data member in relationship" + |> build_error( + 400, + "Check out https://jsonapi.org/format/#crud-creating and https://jsonapi.org/format/#crud-updating-resource-relationships for more info.", + "/data/relationships/#{relationship_name}/data" + ) + end + + @spec missing_relationship_data_id_param_error_attrs(binary()) :: error_attrs() + def missing_relationship_data_id_param_error_attrs(relationship_name) do + "Missing id in relationship data parameter" + |> build_error( + 400, + @relationship_resource_linkage_message, + "/data/relationships/#{relationship_name}/data/id" + ) + end + + @spec missing_relationship_data_type_param_error_attrs(binary()) :: error_attrs() + def missing_relationship_data_type_param_error_attrs(relationship_name) do + "Missing type in relationship data parameter" + |> build_error( + 400, + @relationship_resource_linkage_message, + "/data/relationships/#{relationship_name}/data/type" + ) + end + @spec send_error(Plug.Conn.t(), term()) :: term() def send_error(conn, %{errors: [%{status: status}]} = error), do: send_error(conn, status, error) def send_error(conn, %{errors: errors} = error) when is_list(errors) do - status = Enum.max_by(errors, &Map.get(&1, :status)) + status = + errors + |> Enum.max_by(&Map.get(&1, :status)) + |> Map.get(:status) + send_error(conn, status, error) end @@ -120,12 +168,22 @@ defmodule JSONAPI.ErrorView do |> halt end - @spec serialize_error(map()) :: map() + @spec serialize_error(error_attrs()) :: map() def serialize_error(error) do - error = Map.take(error, [:detail, :id, :links, :meta, :source, :status, :title]) + error = extract_error(error) %{errors: [error]} end + @spec serialize_errors(list()) :: map() + def serialize_errors(errors) do + extracted = Enum.map(errors, &extract_error/1) + %{errors: extracted} + end + + defp extract_error(error) do + Map.take(error, [:detail, :id, :links, :meta, :source, :status, :title]) + end + defp append_field(error, _field, nil), do: error defp append_field(error, :meta, value), do: Map.put(error, :meta, %{meta: value}) defp append_field(error, :source, value), do: Map.put(error, :source, %{pointer: value}) diff --git a/lib/jsonapi/plugs/format_required.ex b/lib/jsonapi/plugs/format_required.ex index a33e9b8d..ad80aa1b 100644 --- a/lib/jsonapi/plugs/format_required.ex +++ b/lib/jsonapi/plugs/format_required.ex @@ -12,6 +12,60 @@ defmodule JSONAPI.FormatRequired do def call(%{method: method} = conn, _opts) when method in ~w[DELETE GET HEAD], do: conn + def call( + %{method: method, params: %{"data" => %{"type" => _, "relationships" => relationships}}} = + conn, + _ + ) + when method in ~w[POST PATCH] and not is_map(relationships) do + send_error(conn, relationships_missing_object()) + end + + def call( + %{ + method: method, + params: %{"data" => %{"type" => _, "relationships" => relationships}} + } = conn, + _ + ) + when method in ~w[POST PATCH] and is_map(relationships) do + errors = + Enum.reduce(relationships, [], fn + {_relationship_name, %{"data" => %{"type" => _type, "id" => _}}}, acc -> + acc + + {relationship_name, %{"data" => %{"type" => _type}}}, acc -> + error = missing_relationship_data_id_param_error_attrs(relationship_name) + [error | acc] + + {relationship_name, %{"data" => %{"id" => _type}}}, acc -> + error = missing_relationship_data_type_param_error_attrs(relationship_name) + [error | acc] + + {relationship_name, %{"data" => %{}}}, acc -> + id_error = missing_relationship_data_id_param_error_attrs(relationship_name) + type_error = missing_relationship_data_type_param_error_attrs(relationship_name) + [id_error | [type_error | acc]] + + {_relationship_name, %{"data" => _}}, acc -> + # Allow things other than resource identifier objects per https://jsonapi.org/format/#document-resource-object-linkage + # - null for empty to-one relationships. + # - an empty array ([]) for empty to-many relationships. + # - an array of resource identifier objects for non-empty to-many relationships. + acc + + {relationship_name, _}, acc -> + error = missing_relationship_data_param_error_attrs(relationship_name) + [error | acc] + end) + + if Enum.empty?(errors) do + conn + else + send_error(conn, serialize_errors(errors)) + end + end + def call(%{method: "POST", params: %{"data" => %{"type" => _}}} = conn, _), do: conn def call(%{method: method, params: %{"data" => [%{"type" => _} | _]}} = conn, _) diff --git a/test/jsonapi/plugs/format_required_test.exs b/test/jsonapi/plugs/format_required_test.exs index ba5e29d7..19bab8b4 100644 --- a/test/jsonapi/plugs/format_required_test.exs +++ b/test/jsonapi/plugs/format_required_test.exs @@ -136,6 +136,315 @@ defmodule JSONAPI.FormatRequiredTest do refute conn.halted end + test "halts and returns an error for a specified relationships param missing an object value ON POST" do + conn = + :post + |> conn( + "/example", + Jason.encode!(%{data: %{type: "example", attributes: %{}, relationships: nil}}) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => [error]} = Jason.decode!(conn.resp_body) + + assert %{ + "source" => %{"pointer" => "/data/relationships"}, + "title" => "Relationships parameter is not an object", + "detail" => + "Check out https://jsonapi.org/format/#document-resource-object-relationships for more info." + } = error + end + + test "halts and returns an error for a specified relationships param missing an object value ON PATCH" do + conn = + :patch + |> conn( + "/example/some-id", + Jason.encode!(%{ + data: %{id: "some-id", type: "example", attributes: %{}, relationships: nil} + }) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => [error]} = Jason.decode!(conn.resp_body) + + assert %{ + "source" => %{"pointer" => "/data/relationships"}, + "title" => "Relationships parameter is not an object", + "detail" => + "Check out https://jsonapi.org/format/#document-resource-object-relationships for more info." + } = error + end + + test "halts and returns an error for relationship objects missing a data member on POST" do + conn = + :post + |> conn( + "/example", + Jason.encode!(%{ + data: %{ + type: "example", + attributes: %{}, + relationships: %{comment: %{id: "some-identifier"}} + } + }) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => [error]} = Jason.decode!(conn.resp_body) + + assert %{ + "source" => %{"pointer" => "/data/relationships/comment/data"}, + "title" => "Missing data member in relationship" + } = error + end + + test "halts and returns an error for relationship objects missing a data member on PATCH" do + conn = + :patch + |> conn( + "/example/some-id", + Jason.encode!(%{ + data: %{ + id: "some-id", + type: "example", + attributes: %{}, + relationships: %{comment: %{id: "some-identifier"}} + } + }) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => [error]} = Jason.decode!(conn.resp_body) + + assert %{ + "source" => %{"pointer" => "/data/relationships/comment/data"}, + "title" => "Missing data member in relationship" + } = error + end + + test "halts and returns an error for relationship objects with a resource linkage missing a type member on POST" do + conn = + :post + |> conn( + "/example", + Jason.encode!(%{ + data: %{ + type: "example", + attributes: %{}, + relationships: %{comment: %{data: %{id: "some-identifier"}}} + } + }) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => [error]} = Jason.decode!(conn.resp_body) + + assert %{ + "source" => %{"pointer" => "/data/relationships/comment/data/type"}, + "title" => "Missing type in relationship data parameter" + } = error + end + + test "halts and returns an error for relationship objects with a resource linkage missing a type member on PATCH" do + conn = + :patch + |> conn( + "/example/some-id", + Jason.encode!(%{ + data: %{ + id: "some-id", + type: "example", + attributes: %{}, + relationships: %{comment: %{data: %{id: "some-identifier"}}} + } + }) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => [error]} = Jason.decode!(conn.resp_body) + + assert %{ + "source" => %{"pointer" => "/data/relationships/comment/data/type"}, + "title" => "Missing type in relationship data parameter" + } = error + end + + test "halts and returns an error for relationship objects with a resource linkage missing an id member on POST" do + conn = + :post + |> conn( + "/example", + Jason.encode!(%{ + data: %{ + type: "example", + attributes: %{}, + relationships: %{comment: %{data: %{type: "comment"}}} + } + }) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => [error]} = Jason.decode!(conn.resp_body) + + assert %{ + "source" => %{"pointer" => "/data/relationships/comment/data/id"}, + "title" => "Missing id in relationship data parameter" + } = error + end + + test "halts and returns an error for relationship objects with a resource linkage missing an id member on PATCH" do + conn = + :patch + |> conn( + "/example/some-id", + Jason.encode!(%{ + data: %{ + id: "some-id", + type: "example", + attributes: %{}, + relationships: %{comment: %{data: %{type: "comment"}}} + } + }) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => [error]} = Jason.decode!(conn.resp_body) + + assert %{ + "source" => %{"pointer" => "/data/relationships/comment/data/id"}, + "title" => "Missing id in relationship data parameter" + } = error + end + + test "halts and returns an error for relationship objects with a resource linkage missing both type and id members on POST" do + conn = + :post + |> conn( + "/example", + Jason.encode!(%{ + data: %{ + type: "example", + attributes: %{}, + relationships: %{comment: %{data: %{}}} + } + }) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => errors} = Jason.decode!(conn.resp_body) + + error_titles = Enum.map(errors, fn error -> Map.fetch!(error, "title") end) + assert "Missing id in relationship data parameter" in error_titles + assert "Missing type in relationship data parameter" in error_titles + + error_pointers = Enum.map(errors, fn error -> get_in(error, ["source", "pointer"]) end) + assert "/data/relationships/comment/data/id" in error_pointers + assert "/data/relationships/comment/data/type" in error_pointers + end + + test "halts and returns an error for relationship objects with a resource linkage missing both type and id members on PATCH" do + conn = + :patch + |> conn( + "/example/some-id", + Jason.encode!(%{ + data: %{ + id: "some-id", + type: "example", + attributes: %{}, + relationships: %{comment: %{data: %{}}} + } + }) + ) + |> call_plug + + assert conn.halted + assert 400 == conn.status + + %{"errors" => errors} = Jason.decode!(conn.resp_body) + + error_titles = Enum.map(errors, fn error -> Map.fetch!(error, "title") end) + assert "Missing id in relationship data parameter" in error_titles + assert "Missing type in relationship data parameter" in error_titles + + error_pointers = Enum.map(errors, fn error -> get_in(error, ["source", "pointer"]) end) + assert "/data/relationships/comment/data/id" in error_pointers + assert "/data/relationships/comment/data/type" in error_pointers + end + + test "accepts a relationships object with well-formed resource linkages on POST" do + conn = + :post + |> conn( + "/example", + Jason.encode!(%{ + data: %{ + type: "example", + attributes: %{}, + relationships: %{ + comment: %{data: %{type: "comment", id: "some-identifier"}}, + post: %{data: nil}, + reviews: %{data: []} + } + } + }) + ) + |> call_plug + + refute conn.halted + end + + test "accepts a relationships object with well-formed resource linkages on PATCH" do + conn = + :patch + |> conn( + "/example/some-id", + Jason.encode!(%{ + data: %{ + id: "some-id", + type: "example", + attributes: %{}, + relationships: %{ + comment: %{data: %{type: "comment", id: "some-identifier"}}, + post: %{data: nil}, + reviews: %{data: []} + } + } + }) + ) + |> call_plug + + refute conn.halted + end + test "passes request through" do conn = :post