Skip to content

Commit

Permalink
Fully Support Sparse Fieldsets
Browse files Browse the repository at this point in the history
Whilst the `QueryParser` was correctly identifying requested fieldsets,
nothing was done to actually support this.

This change prunes returned fields to those requested should it be the
case.

Note that this change also includes a few more typespecs for functions I
touched or read.

Resolves #120
Closes #156
  • Loading branch information
jherdman committed Feb 12, 2019
1 parent b467f43 commit 381236e
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 17 deletions.
28 changes: 20 additions & 8 deletions lib/jsonapi/plugs/query_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule JSONAPI.QueryParser do
alias JSONAPI.Exceptions.InvalidQuery
alias Plug.Conn
import JSONAPI.Utils.IncludeTree
import JSONAPI.Utils.String, only: [underscore: 1]

@moduledoc """
Implements a fully JSONAPI V1 spec for parsing a complex query string and returning elixir
Expand Down Expand Up @@ -67,10 +68,12 @@ defmodule JSONAPI.QueryParser do
For more details please see `JSONAPI.UnderscoreParameters`.
"""

@impl Plug
def init(opts) do
build_config(opts)
end

@impl Plug
def call(conn, opts) do
query_params_config_struct =
conn
Expand Down Expand Up @@ -111,7 +114,8 @@ defmodule JSONAPI.QueryParser do
end
end

def parse_fields(config, map) when map_size(map) == 0, do: config
@spec parse_fields(Config.t(), map()) :: Config.t() | no_return()
def parse_fields(%Config{} = config, fields) when fields == %{}, do: config

def parse_fields(%Config{} = config, fields) do
Enum.reduce(fields, config, fn {type, value}, acc ->
Expand All @@ -121,9 +125,14 @@ defmodule JSONAPI.QueryParser do
|> Enum.into(MapSet.new())

requested_fields =
value
|> String.split(",")
|> Enum.into(MapSet.new(), &String.to_atom/1)
try do
value
|> String.split(",")
|> Enum.map(&underscore/1)
|> Enum.into(MapSet.new(), &String.to_existing_atom/1)
rescue
ArgumentError -> raise_invalid_field_names(value, config.view.type())
end

unless MapSet.subset?(requested_fields, valid_fields) do
bad_fields =
Expand All @@ -132,10 +141,7 @@ defmodule JSONAPI.QueryParser do
|> MapSet.to_list()
|> Enum.join(",")

raise InvalidQuery,
resource: config.view.type(),
param: bad_fields,
param_type: :fields
raise_invalid_field_names(bad_fields, config.view.type())
end

%{acc | fields: Map.put(acc.fields, type, MapSet.to_list(requested_fields))}
Expand Down Expand Up @@ -241,6 +247,12 @@ defmodule JSONAPI.QueryParser do
raise InvalidQuery, resource: resource_type, param: param, param_type: :include
end

@spec raise_invalid_field_names(bad_fields :: String.t(), resource_type :: String.t()) ::
no_return
defp raise_invalid_field_names(bad_fields, resource_type) do
raise InvalidQuery, resource: resource_type, param: bad_fields, param_type: :fields
end

defp build_config(opts) do
view = Keyword.fetch!(opts, :view)
struct(Config, opts: opts, view: view)
Expand Down
66 changes: 61 additions & 5 deletions lib/jsonapi/view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ defmodule JSONAPI.View do
## Fields
By default, the resulting JSON document consists of fields, defined in fields/0
By default, the resulting JSON document consists of fields, defined in `fields/0`
function. You can define custom fields or override current fields by defining
inside the view function `field_name/2` that takes data and conn as arguments.
inside the view function `field_name/2` that takes `data` and `conn` as arguments.
defmodule UserView do
use JSONAPI.View
Expand All @@ -51,6 +51,32 @@ defmodule JSONAPI.View do
def relationships, do: []
end
Fields may be omitted manually using the `hidden/1` function.
defmodule UserView do
use JSONAPI.View
def fields, do: [:id, :username, :email]
def type, do: "user"
def hidden(_data) do
[:email] # will be removed from the response
end
end
In order to use [sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)
you must include the `JSONAPI.QueryParser` plug.
### Advanced Fields Usage
Note that the overriddable `attributes/2` method can be used for more control
over which fields are serialized from your view. It is **strongly** recommended
that you exhaustively try a combination of `fields/0`, and `hidden/1` first as
this method will likely be deprecated in a future release. Should you desire to
do so, you may wish to call `visisble_fields/2` within your implementation in
order to get the list of fields that will be sent to the client.
## Relationships
Currently the relationships callback expects that a map is returned
Expand Down Expand Up @@ -84,6 +110,9 @@ defmodule JSONAPI.View do
The default behaviour for `host` and `scheme` is to derive it from the `conn` provided, while the
default style for presentation in names is to be underscored and not dashed.
"""

alias Plug.Conn

defmacro __using__(opts \\ []) do
{type, opts} = Keyword.pop(opts, :type)
{namespace, _opts} = Keyword.pop(opts, :namespace, "")
Expand All @@ -104,10 +133,37 @@ defmodule JSONAPI.View do
def type, do: raise("Need to implement type/0")
end

def attributes(data, conn) do
hidden = hidden(data)
defp requested_fields_for_type(%Conn{assigns: %{jsonapi_query: %{fields: fields}}} = conn) do
fields[type()]
end

visible_fields = fields() -- hidden
defp requested_fields_for_type(_conn), do: nil

defp fields_for_type(requested_fields, fields) when requested_fields in [nil, %{}],
do: fields

defp fields_for_type(requested_fields, fields) do
fields
|> MapSet.new()
|> MapSet.intersection(MapSet.new(requested_fields))
|> MapSet.to_list()
end

@spec visible_fields(map(), conn :: nil | Conn.t()) :: list(atom)
def visible_fields(data, conn) do
all_fields =
conn
|> requested_fields_for_type()
|> fields_for_type(fields())

hidden_fields = hidden(data)

all_fields -- hidden_fields
end

@spec attributes(map(), conn :: nil | Conn.t()) :: map()
def attributes(data, conn) do
visible_fields = visible_fields(data, conn)

Enum.reduce(visible_fields, %{}, fn field, intermediate_map ->
value =
Expand Down
4 changes: 4 additions & 0 deletions test/jsonapi/plugs/query_parser_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ defmodule JSONAPI.QueryParserTest do
assert_raise InvalidQuery, "invalid fields, blag for type mytype", fn ->
parse_fields(config, %{"mytype" => "blag"})
end

assert_raise InvalidQuery, "invalid fields, username for type mytype", fn ->
parse_fields(config, %{"mytype" => "username"})
end
end

test "get_view_for_type/2" do
Expand Down
31 changes: 30 additions & 1 deletion test/jsonapi/view_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,28 @@ defmodule JSONAPI.ViewTest do
assert data.meta.total_pages == 100
end

test "attributes/2 does not display hidden fields with deprecated hidden/0" do
test "visible_fields/2 returns all field names by default" do
data = %{age: 100, first_name: "Jason", last_name: "S", password: "securepw"}

assert [:age, :first_name, :last_name, :full_name] ==
UserView.visible_fields(data, %Plug.Conn{})
end

test "visible_fields/2 removes any hidden field names" do
data = %{title: "Hidden body", body: "Something"}

assert [:title] == PostView.visible_fields(data, %Plug.Conn{})
end

test "visible_fields/2 trims returned field names to only those requested" do
data = %{body: "Chunky", title: "Bacon"}
config = %JSONAPI.Config{fields: %{PostView.type() => [:body]}}
conn = %Plug.Conn{assigns: %{jsonapi_query: config}}

assert [:body] == PostView.visible_fields(data, conn)
end

test "attributes/2 does not display hidden fields" do
expected_map = %{age: 100, first_name: "Jason", last_name: "S", full_name: "Jason S"}

assert expected_map ==
Expand All @@ -161,4 +182,12 @@ defmodule JSONAPI.ViewTest do
nil
)
end

test "attributes/2 can return only requested fields" do
data = %{body: "Chunky", title: "Bacon"}
config = %JSONAPI.Config{fields: %{PostView.type() => [:body]}}
conn = %Plug.Conn{assigns: %{jsonapi_query: config}}

assert %{body: "Chunky"} == PostView.attributes(data, conn)
end
end
73 changes: 70 additions & 3 deletions test/jsonapi_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ defmodule JSONAPITest do
defmodule PostView do
use JSONAPI.View

def fields, do: [:text, :body, :excerpt]
def fields, do: [:text, :body, :excerpt, :first_character]
def type, do: "mytype"

def relationships do
[author: {JSONAPITest.UserView, :include}, other_user: JSONAPITest.UserView]
end

def excerpt(post, _conn) do
letter = String.slice(post.text, 0..1)
letter
String.slice(post.text, 0..1)
end

def first_character(post, _conn) do
String.first(post.text)
end
end

Expand Down Expand Up @@ -275,6 +278,70 @@ defmodule JSONAPITest do
assert Map.has_key?(json, "links")
end

describe "with an underscored API" do
setup do
Application.put_env(:jsonapi, :field_transformation, :underscore)

on_exit(fn ->
Application.delete_env(:jsonapi, :field_transformation)
end)

{:ok, []}
end

test "handles sparse fields properly" do
conn =
:get
|> conn("/posts?include=other_user.company&fields[mytype]=text,excerpt,first_character")
|> Plug.Conn.assign(:data, [@default_data])
|> MyPostPlug.call([])

assert %{
"data" => [
%{"attributes" => attributes}
]
} = Jason.decode!(conn.resp_body)

assert %{
"text" => "Hello",
"excerpt" => "He",
"first_character" => "H"
} == attributes
end
end

describe "with a dasherized API" do
setup do
Application.put_env(:jsonapi, :field_transformation, :dasherize)

on_exit(fn ->
Application.delete_env(:jsonapi, :field_transformation)
end)

{:ok, []}
end

test "handles sparse fields properly" do
conn =
:get
|> conn("/posts?include=other_user.company&fields[mytype]=text,excerpt,first-character")
|> Plug.Conn.assign(:data, [@default_data])
|> MyPostPlug.call([])

assert %{
"data" => [
%{"attributes" => attributes}
]
} = Jason.decode!(conn.resp_body)

assert %{
"text" => "Hello",
"excerpt" => "He",
"first-character" => "H"
} == attributes
end
end

test "omits explicit nil meta values as per http://jsonapi.org/format/#document-meta" do
conn =
:get
Expand Down

0 comments on commit 381236e

Please sign in to comment.