Skip to content

Commit c87faa1

Browse files
committed
Add JSON:API sparse fieldset relationship support
The JSON:API specification allows for sparse fieldsets to limit both attribute and relationship serialization. Until now, this library only appliled sparse fieldset handling to attributes. This adds relationship support.
1 parent 6ae0e9e commit c87faa1

File tree

6 files changed

+104
-7
lines changed

6 files changed

+104
-7
lines changed

lib/jsonapi/plugs/query_parser.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,9 @@ defmodule JSONAPI.QueryParser do
282282
@spec get_valid_fields_for_type(Config.t(), String.t()) :: list(atom())
283283
def get_valid_fields_for_type(%Config{view: view}, type) do
284284
if type == view.type() do
285-
view.fields()
285+
view.valid_attrs_and_rels()
286286
else
287-
get_view_for_type(view, type).fields()
287+
get_view_for_type(view, type).valid_attrs_and_rels()
288288
end
289289
end
290290

lib/jsonapi/serializer.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ defmodule JSONAPI.Serializer do
8181
@spec encode_relationships(Conn.t(), document(), tuple(), list()) :: tuple()
8282
def encode_relationships(conn, doc, {view, data, _, _} = view_info, options) do
8383
data
84-
|> view.resource_relationships()
84+
|> view.visible_relationships(conn)
8585
|> Enum.filter(&assoc_loaded?(Map.get(data, get_data_key(&1))))
8686
|> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2, options))
8787
end
@@ -314,7 +314,7 @@ defmodule JSONAPI.Serializer do
314314
end
315315

316316
defp get_default_includes(view, data) do
317-
rels = view.resource_relationships(data)
317+
rels = view.visible_relationships(data, nil)
318318

319319
Enum.filter(rels, &include_rel_by_default/1)
320320
end
@@ -326,7 +326,7 @@ defmodule JSONAPI.Serializer do
326326
end
327327

328328
defp get_query_includes(view, query_includes, data) do
329-
rels = view.resource_relationships(data)
329+
rels = view.visible_relationships(data, nil)
330330

331331
query_includes
332332
|> Enum.map(fn

lib/jsonapi/view.ex

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ defmodule JSONAPI.View do
4747
2-arity function inside the view that takes `data` and `conn` as arguments and has
4848
the same name as the field it will be producing. Refer to our `fullname/2` example below.
4949
50+
For historical reasons, the `View` module's "fields" are actually exclusively
51+
attributes. That is, although JSON:API defines fields to be both attributes
52+
and relationships, you should read the `View` module's macros as `field` ->
53+
`attribute` and `relationship` -> `relationship`.
54+
5055
defmodule UserView do
5156
use JSONAPI.View
5257
@@ -233,6 +238,7 @@ defmodule JSONAPI.View do
233238
@callback path() :: String.t() | nil
234239
@callback relationships() :: resource_relationships()
235240
@callback polymorphic_relationships(data()) :: resource_relationships()
241+
@callback visible_relationships(data(), Conn.t() | nil) :: resource_relationships()
236242
@callback type() :: resource_type() | nil
237243
@callback polymorphic_type(data()) :: resource_type() | nil
238244
@callback url_for(data(), Conn.t() | nil) :: String.t()
@@ -377,6 +383,10 @@ defmodule JSONAPI.View do
377383
def visible_fields(data, conn),
378384
do: View.visible_fields(__MODULE__, data, conn)
379385

386+
@impl View
387+
def visible_relationships(data, conn),
388+
do: View.visible_relationships(__MODULE__, data, conn)
389+
380390
def resource_fields(data) do
381391
if @polymorphic_resource? do
382392
polymorphic_fields(data)
@@ -401,6 +411,24 @@ defmodule JSONAPI.View do
401411
end
402412
end
403413

414+
@doc """
415+
Get valid attribute and relationship names (collectively known in
416+
JSON:API as "fields", but the name "fields" is taken and left intact for
417+
backwards compatibility).
418+
419+
This function can be used before knowing the data being serialized and as
420+
a consequence it cannot perform polymorphic logic; this is the only way
421+
to operate from the QueryParser Plug.
422+
"""
423+
@spec valid_attrs_and_rels() :: [atom()]
424+
def valid_attrs_and_rels do
425+
if @polymorphic_resource? do
426+
[]
427+
else
428+
fields() ++ Enum.map(relationships(), fn {name, _} -> name end)
429+
end
430+
end
431+
404432
defoverridable View
405433

406434
def index(models, conn, _params, meta \\ nil, options \\ []),
@@ -537,6 +565,22 @@ defmodule JSONAPI.View do
537565
all_fields -- hidden_fields
538566
end
539567

568+
@spec visible_relationships(t(), data(), Conn.t() | nil) :: resource_relationships()
569+
def visible_relationships(view, data, conn) do
570+
view
571+
|> requested_fields_for_type(data, conn)
572+
|> net_relationships_for_type(view.resource_relationships(data))
573+
end
574+
575+
defp net_relationships_for_type(requested_fields, relationships) when requested_fields in [nil, %{}],
576+
do: relationships
577+
578+
defp net_relationships_for_type(requested_fields, relationships) do
579+
Enum.filter(relationships, fn {name, _} ->
580+
Enum.any?(requested_fields, &Kernel.==(&1, name))
581+
end)
582+
end
583+
540584
defp net_fields_for_type(requested_fields, fields) when requested_fields in [nil, %{}],
541585
do: fields
542586

test/jsonapi/plugs/query_parser_test.exs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
defmodule JSONAPI.QueryParserTest do
22
use ExUnit.Case
3-
use Plug.Test
43

54
import JSONAPI.QueryParser
5+
import Plug.{Conn, Test}
66

77
alias JSONAPI.Config
88
alias JSONAPI.Exceptions.InvalidQuery
@@ -153,7 +153,7 @@ defmodule JSONAPI.QueryParserTest do
153153
assert parse_include(config, "author,comments.user").include == [:author, {:comments, :user}]
154154
end
155155

156-
test "parse_fields/2 turns a fields map into a map of validated fields" do
156+
test "parse_fields/2 turns a fields map containing an attribute into a map of validated fields" do
157157
config = struct(Config, view: MyView)
158158
assert parse_fields(config, %{"mytype" => "id,text"}).fields == %{"mytype" => [:id, :text]}
159159
end
@@ -163,6 +163,11 @@ defmodule JSONAPI.QueryParserTest do
163163
assert parse_fields(config, %{"mytype" => ""}).fields == %{"mytype" => []}
164164
end
165165

166+
test "parse_fields/2 accepts relationship fields" do
167+
config = struct(Config, view: MyView)
168+
assert parse_fields(config, %{"mytype" => "id,author"}).fields == %{"mytype" => [:id, :author]}
169+
end
170+
166171
test "parse_fields/2 raises on invalid parsing" do
167172
config = struct(Config, view: MyView)
168173

test/jsonapi/serializer_test.exs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,32 @@ defmodule JSONAPI.SerializerTest do
304304
assert Enum.count(encoded[:included]) == 4
305305
end
306306

307+
test "serialize handles sparse fieldsets" do
308+
data = %{
309+
id: 1,
310+
text: "Hello",
311+
body: "Hello world",
312+
author: %{id: 2, username: "jason"},
313+
best_comments: [
314+
%{id: 5, text: "greatest comment ever", user: %{id: 4, username: "jack"}},
315+
%{id: 6, text: "not so great", user: %{id: 2, username: "jason"}}
316+
]
317+
}
318+
319+
conn =
320+
%Plug.Conn{}
321+
|> Plug.Conn.fetch_query_params()
322+
|> Plug.Conn.assign(:jsonapi_query, %Config{fields: %{PostView.type() => [:author, :text]}})
323+
324+
encoded = Serializer.serialize(PostView, data, conn)
325+
326+
assert is_nil(encoded[:data][:attributes][:body])
327+
assert %{text: "Hello"} = encoded[:data][:attributes]
328+
329+
assert is_nil(encoded[:data][:relationships][:best_comments])
330+
assert %{author: %{data: %{id: "2", type: "user"}}} = encoded[:data][:relationships]
331+
end
332+
307333
test "serialize handles a list" do
308334
data = %{
309335
id: 1,

test/jsonapi/view_test.exs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ defmodule JSONAPI.ViewTest do
1313
end
1414

1515
def hidden(_), do: []
16+
17+
def relationships do
18+
[{:comments, JSONAPI.ViewTest.CommentView}]
19+
end
1620
end
1721

1822
defmodule CommentView do
@@ -41,6 +45,8 @@ defmodule JSONAPI.ViewTest do
4145

4246
defmodule CarView do
4347
use JSONAPI.View, type: "cars", namespace: ""
48+
49+
def fields, do: []
4450
end
4551

4652
defmodule DynamicView do
@@ -369,4 +375,20 @@ defmodule JSONAPI.ViewTest do
369375
some_other_field: "foo"
370376
} == PolymorphicView.attributes(data, conn)
371377
end
378+
379+
test "visible_relationships/2 returns all relationship fields by default" do
380+
data = %{comments: [%{body: "hello"}]}
381+
config = %JSONAPI.Config{}
382+
conn = %Plug.Conn{assigns: %{jsonapi_query: config}}
383+
384+
assert [{:comments, CommentView}] = PostView.visible_relationships(data, conn)
385+
end
386+
387+
test "visible_relationships/2 can omit relationships based on requested fields" do
388+
data = %{body: "Chunky", title: "Bacon", comments: [%{body: "hello"}]}
389+
config = %JSONAPI.Config{fields: %{PostView.type() => [:body]}}
390+
conn = %Plug.Conn{assigns: %{jsonapi_query: config}}
391+
392+
assert [] = PostView.visible_relationships(data, conn)
393+
end
372394
end

0 commit comments

Comments
 (0)