diff --git a/lib/jsonapi/plugs/query_parser.ex b/lib/jsonapi/plugs/query_parser.ex index 636b114a..2335a4a6 100644 --- a/lib/jsonapi/plugs/query_parser.ex +++ b/lib/jsonapi/plugs/query_parser.ex @@ -282,9 +282,9 @@ defmodule JSONAPI.QueryParser do @spec get_valid_fields_for_type(Config.t(), String.t()) :: list(atom()) def get_valid_fields_for_type(%Config{view: view}, type) do if type == view.type() do - view.fields() + view.valid_attrs_and_rels() else - get_view_for_type(view, type).fields() + get_view_for_type(view, type).valid_attrs_and_rels() end end diff --git a/lib/jsonapi/serializer.ex b/lib/jsonapi/serializer.ex index 69a7dbff..d855f527 100644 --- a/lib/jsonapi/serializer.ex +++ b/lib/jsonapi/serializer.ex @@ -81,7 +81,7 @@ defmodule JSONAPI.Serializer do @spec encode_relationships(Conn.t(), document(), tuple(), list()) :: tuple() def encode_relationships(conn, doc, {view, data, _, _} = view_info, options) do data - |> view.resource_relationships() + |> view.visible_relationships(conn) |> Enum.filter(&assoc_loaded?(Map.get(data, get_data_key(&1)))) |> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2, options)) end @@ -314,7 +314,7 @@ defmodule JSONAPI.Serializer do end defp get_default_includes(view, data) do - rels = view.resource_relationships(data) + rels = view.visible_relationships(data, nil) Enum.filter(rels, &include_rel_by_default/1) end @@ -326,7 +326,7 @@ defmodule JSONAPI.Serializer do end defp get_query_includes(view, query_includes, data) do - rels = view.resource_relationships(data) + rels = view.visible_relationships(data, nil) query_includes |> Enum.map(fn diff --git a/lib/jsonapi/view.ex b/lib/jsonapi/view.ex index 12fd8e73..c7ce40e5 100644 --- a/lib/jsonapi/view.ex +++ b/lib/jsonapi/view.ex @@ -47,6 +47,11 @@ defmodule JSONAPI.View do 2-arity function inside the view that takes `data` and `conn` as arguments and has the same name as the field it will be producing. Refer to our `fullname/2` example below. + For historical reasons, the `View` module's "fields" are actually exclusively + attributes. That is, although JSON:API defines fields to be both attributes + and relationships, you should read the `View` module's macros as `field` -> + `attribute` and `relationship` -> `relationship`. + defmodule UserView do use JSONAPI.View @@ -233,6 +238,7 @@ defmodule JSONAPI.View do @callback path() :: String.t() | nil @callback relationships() :: resource_relationships() @callback polymorphic_relationships(data()) :: resource_relationships() + @callback visible_relationships(data(), Conn.t() | nil) :: resource_relationships() @callback type() :: resource_type() | nil @callback polymorphic_type(data()) :: resource_type() | nil @callback url_for(data(), Conn.t() | nil) :: String.t() @@ -377,6 +383,10 @@ defmodule JSONAPI.View do def visible_fields(data, conn), do: View.visible_fields(__MODULE__, data, conn) + @impl View + def visible_relationships(data, conn), + do: View.visible_relationships(__MODULE__, data, conn) + def resource_fields(data) do if @polymorphic_resource? do polymorphic_fields(data) @@ -401,6 +411,24 @@ defmodule JSONAPI.View do end end + @doc """ + Get valid attribute and relationship names (collectively known in + JSON:API as "fields", but the name "fields" is taken and left intact for + backwards compatibility). + + This function can be used before knowing the data being serialized and as + a consequence it cannot perform polymorphic logic; this is the only way + to operate from the QueryParser Plug. + """ + @spec valid_attrs_and_rels() :: [atom()] + def valid_attrs_and_rels do + if @polymorphic_resource? do + [] + else + fields() ++ Enum.map(relationships(), fn {name, _} -> name end) + end + end + defoverridable View def index(models, conn, _params, meta \\ nil, options \\ []), @@ -537,6 +565,22 @@ defmodule JSONAPI.View do all_fields -- hidden_fields end + @spec visible_relationships(t(), data(), Conn.t() | nil) :: resource_relationships() + def visible_relationships(view, data, conn) do + view + |> requested_fields_for_type(data, conn) + |> net_relationships_for_type(view.resource_relationships(data)) + end + + defp net_relationships_for_type(requested_fields, relationships) when requested_fields in [nil, %{}], + do: relationships + + defp net_relationships_for_type(requested_fields, relationships) do + Enum.filter(relationships, fn {name, _} -> + Enum.any?(requested_fields, &Kernel.==(&1, name)) + end) + end + defp net_fields_for_type(requested_fields, fields) when requested_fields in [nil, %{}], do: fields diff --git a/test/jsonapi/plugs/query_parser_test.exs b/test/jsonapi/plugs/query_parser_test.exs index 00f48225..e647f207 100644 --- a/test/jsonapi/plugs/query_parser_test.exs +++ b/test/jsonapi/plugs/query_parser_test.exs @@ -1,8 +1,8 @@ defmodule JSONAPI.QueryParserTest do use ExUnit.Case - use Plug.Test import JSONAPI.QueryParser + import Plug.{Conn, Test} alias JSONAPI.Config alias JSONAPI.Exceptions.InvalidQuery @@ -153,7 +153,7 @@ defmodule JSONAPI.QueryParserTest do assert parse_include(config, "author,comments.user").include == [:author, {:comments, :user}] end - test "parse_fields/2 turns a fields map into a map of validated fields" do + test "parse_fields/2 turns a fields map containing an attribute into a map of validated fields" do config = struct(Config, view: MyView) assert parse_fields(config, %{"mytype" => "id,text"}).fields == %{"mytype" => [:id, :text]} end @@ -163,6 +163,11 @@ defmodule JSONAPI.QueryParserTest do assert parse_fields(config, %{"mytype" => ""}).fields == %{"mytype" => []} end + test "parse_fields/2 accepts relationship fields" do + config = struct(Config, view: MyView) + assert parse_fields(config, %{"mytype" => "id,author"}).fields == %{"mytype" => [:id, :author]} + end + test "parse_fields/2 raises on invalid parsing" do config = struct(Config, view: MyView) diff --git a/test/jsonapi/serializer_test.exs b/test/jsonapi/serializer_test.exs index a7fbc35c..5fb62c98 100644 --- a/test/jsonapi/serializer_test.exs +++ b/test/jsonapi/serializer_test.exs @@ -304,6 +304,32 @@ defmodule JSONAPI.SerializerTest do assert Enum.count(encoded[:included]) == 4 end + test "serialize handles sparse fieldsets" do + data = %{ + id: 1, + text: "Hello", + body: "Hello world", + author: %{id: 2, username: "jason"}, + best_comments: [ + %{id: 5, text: "greatest comment ever", user: %{id: 4, username: "jack"}}, + %{id: 6, text: "not so great", user: %{id: 2, username: "jason"}} + ] + } + + conn = + %Plug.Conn{} + |> Plug.Conn.fetch_query_params() + |> Plug.Conn.assign(:jsonapi_query, %Config{fields: %{PostView.type() => [:author, :text]}}) + + encoded = Serializer.serialize(PostView, data, conn) + + assert is_nil(encoded[:data][:attributes][:body]) + assert %{text: "Hello"} = encoded[:data][:attributes] + + assert is_nil(encoded[:data][:relationships][:best_comments]) + assert %{author: %{data: %{id: "2", type: "user"}}} = encoded[:data][:relationships] + end + test "serialize handles a list" do data = %{ id: 1, diff --git a/test/jsonapi/view_test.exs b/test/jsonapi/view_test.exs index 85e74c8e..2aa2758a 100644 --- a/test/jsonapi/view_test.exs +++ b/test/jsonapi/view_test.exs @@ -13,6 +13,10 @@ defmodule JSONAPI.ViewTest do end def hidden(_), do: [] + + def relationships do + [{:comments, JSONAPI.ViewTest.CommentView}] + end end defmodule CommentView do @@ -41,6 +45,8 @@ defmodule JSONAPI.ViewTest do defmodule CarView do use JSONAPI.View, type: "cars", namespace: "" + + def fields, do: [] end defmodule DynamicView do @@ -369,4 +375,20 @@ defmodule JSONAPI.ViewTest do some_other_field: "foo" } == PolymorphicView.attributes(data, conn) end + + test "visible_relationships/2 returns all relationship fields by default" do + data = %{comments: [%{body: "hello"}]} + config = %JSONAPI.Config{} + conn = %Plug.Conn{assigns: %{jsonapi_query: config}} + + assert [{:comments, CommentView}] = PostView.visible_relationships(data, conn) + end + + test "visible_relationships/2 can omit relationships based on requested fields" do + data = %{body: "Chunky", title: "Bacon", comments: [%{body: "hello"}]} + config = %JSONAPI.Config{fields: %{PostView.type() => [:body]}} + conn = %Plug.Conn{assigns: %{jsonapi_query: config}} + + assert [] = PostView.visible_relationships(data, conn) + end end