Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support renaming of relationships #270

Merged
merged 9 commits into from
Jan 14, 2023
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ or `"index.json"` normally.
If you'd like to use this without Phoenix simply use the `JSONAPI.View` and call
`JSONAPI.Serializer.serialize(MyApp.PostView, data, conn, meta)`.

## Renaming relationships
If a relationship has a different name in the backend than you would like it to in your API,
you can rewrite its name in the `JSONAPI.View`. You pair the view with the name of the relationship
used in the data (e.g. Ecto schema) to achieve this. Note that you can use a triple instead
mattpolzin marked this conversation as resolved.
Show resolved Hide resolved
of a pair to add the instruction to always include the relation if desired.

```elixir
defmodule MyApp.PostView do
use JSONAPI.View, type: "posts"

def relationships do
# The `author` will be exposed as `creator` and the `comments` will be
# exposed as `critiques` (for some reason).
[creator: {:author, MyApp.UserView, :include},
critiques: {:comments, MyApp.CommentView}]
end
end
```

## Parsing and validating a JSONAPI Request

In your controller you may add
Expand Down
90 changes: 70 additions & 20 deletions lib/jsonapi/serializer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -79,29 +79,26 @@ defmodule JSONAPI.Serializer do
@spec encode_relationships(Conn.t(), document(), tuple(), list()) :: tuple()
def encode_relationships(conn, doc, {view, data, _, _} = view_info, options) do
view.relationships()
|> Enum.filter(&assoc_loaded?(Map.get(data, elem(&1, 0))))
|> Enum.filter(&data_loaded?(Map.get(data, get_data_key(&1))))
|> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2, options))
end

@spec build_relationships(Conn.t(), tuple(), tuple(), tuple(), list()) :: tuple()
defp get_data_key(rel_config), do: elem(extrapolate_relationship_config(rel_config), 1)

@spec build_relationships(Conn.t(), tuple(), term(), term(), module(), tuple(), list()) ::
tuple()
def build_relationships(
conn,
{view, data, query_includes, valid_includes},
{key, include_view},
{parent_view, parent_data, query_includes, valid_includes},
relationship_name,
rel_data,
rel_view,
acc,
options
) do
rel_view =
case include_view do
{view, :include} -> view
view -> view
end

rel_data = Map.get(data, key)

# Build the relationship url
rel_key = transform_fields(key)
rel_url = view.url_for_rel(data, rel_key, conn)
rel_key = transform_fields(relationship_name)
rel_url = parent_view.url_for_rel(parent_data, rel_key, conn)

# Build the relationship
acc =
Expand All @@ -111,14 +108,14 @@ defmodule JSONAPI.Serializer do
encode_relation({rel_view, rel_data, rel_url, conn})
)

valid_include_view = include_view(valid_includes, key)
valid_include_view = include_view(valid_includes, relationship_name)

if {rel_view, :include} == valid_include_view && data_loaded?(rel_data) do
rel_query_includes =
if is_list(query_includes) do
query_includes
|> Enum.reduce([], fn
{^key, value}, acc -> acc ++ [value]
{^relationship_name, value}, acc -> acc ++ [value]
_, acc -> acc
end)
|> List.flatten()
Expand All @@ -135,6 +132,54 @@ defmodule JSONAPI.Serializer do
end
end

@spec build_relationships(Conn.t(), tuple(), tuple(), tuple(), list()) :: tuple()
def build_relationships(
conn,
{_parent_view, data, _query_includes, _valid_includes} = parent_info,
rel_config,
acc,
options
) do
{rewrite_key, data_key, rel_view, _include} = extrapolate_relationship_config(rel_config)

rel_data = Map.get(data, data_key)

build_relationships(
conn,
parent_info,
rewrite_key,
rel_data,
rel_view,
acc,
options
)
end

@doc """
Given the relationship config entry provided by a JSONAPI.View, produce
the extrapolated config tuple containing:
- The name of the relationship to be used when serializing
- The key in the data the relationship is found under
- The relationship resource's JSONAPI.View module
- A boolean for whether the relationship is included by default or not
"""
@spec extrapolate_relationship_config(tuple()) :: {atom(), atom(), module(), boolean()}
def extrapolate_relationship_config({rewrite_key, {data_key, view, :include}}) do
{rewrite_key, data_key, view, true}
end

def extrapolate_relationship_config({data_key, {view, :include}}) do
{data_key, data_key, view, true}
end

def extrapolate_relationship_config({rewrite_key, {data_key, view}}) do
{rewrite_key, data_key, view, false}
end

def extrapolate_relationship_config({data_key, view}) do
{data_key, data_key, view, false}
end

defp include_view(valid_includes, key) when is_list(valid_includes) do
valid_includes
|> Keyword.get(key)
Expand All @@ -143,7 +188,9 @@ defmodule JSONAPI.Serializer do

defp include_view(view, _key), do: generate_view_tuple(view)

defp generate_view_tuple({_rewrite_key, view, :include}), do: {view, :include}
defp generate_view_tuple({view, :include}), do: {view, :include}
defp generate_view_tuple({_rewrite_key, view}), do: {view, :include}
defp generate_view_tuple(view) when is_atom(view), do: {view, :include}

@spec data_loaded?(map() | list()) :: boolean()
Expand Down Expand Up @@ -229,10 +276,13 @@ defmodule JSONAPI.Serializer do
defp get_default_includes(view) do
rels = view.relationships()

Enum.filter(rels, fn
{_k, {_v, :include}} -> true
_ -> false
end)
Enum.filter(rels, &include_rel_by_default/1)
end

defp include_rel_by_default(rel_config) do
{_rel_key, _data_key, _view, include_by_default} = extrapolate_relationship_config(rel_config)

include_by_default
end

defp get_query_includes(view, query_includes) do
Expand Down
92 changes: 92 additions & 0 deletions test/jsonapi/serializer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,23 @@ defmodule JSONAPI.SerializerTest do
end
end

defmodule CommentaryView do
use JSONAPI.View, type: "comment"

def fields, do: [:text]

def relationships do
# renames "user1" data property to "commenter" JSON:API relationship (and specifies default inclusion)
# renames "user2" to "witness"
# leaves "user3" name alone
[
commenter: {:user1, JSONAPI.SerializerTest.UserView, :include},
witness: {:user2, JSONAPI.SerializerTest.UserView},
user3: JSONAPI.SerializerTest.UserView
]
end
end

defmodule NotIncludedView do
use JSONAPI.View

Expand Down Expand Up @@ -325,6 +342,49 @@ defmodule JSONAPI.SerializerTest do
assert encoded[:links][:self] == "http://www.example.com/mytype"
end

test "serialize will rename relationships" do
data = %{
id: 1,
text: "hello world",
user1: %{
id: 2,
username: "hi",
first_name: "hello",
last_name: "world"
},
user2: %{
id: 3,
username: "hi",
first_name: "hello",
last_name: "world"
},
user3: %{
id: 4,
username: "hi",
first_name: "hello",
last_name: "world"
}
}

conn =
%Plug.Conn{
assigns: %{
jsonapi_query: %Config{}
}
}
|> Plug.Conn.fetch_query_params()

encoded = Serializer.serialize(CommentaryView, data, conn)

assert encoded.data.relationships.commenter != nil
assert encoded.data.relationships.witness != nil
assert encoded.data.relationships.user3 != nil
refute Map.has_key?(encoded.data.relationships, :user1)
refute Map.has_key?(encoded.data.relationships, :user2)

assert Enum.count(encoded.included) == 1
end

test "serialize handles including from the query" do
data = %{
id: 1,
Expand Down Expand Up @@ -679,4 +739,36 @@ defmodule JSONAPI.SerializerTest do
assert List.first(encoded[:data])[:links][:self] ==
"http://www.example.com/mytype/1"
end

test "extrapolates relationship config simplest case" do
config =
IndustryView.relationships()
|> List.first()
|> Serializer.extrapolate_relationship_config()

assert config == {:tags, :tags, JSONAPI.SerializerTest.TagView, false}
end

test "extrapolates relationship config with default include" do
configs =
PostView.relationships()
|> Enum.map(&Serializer.extrapolate_relationship_config/1)

assert configs == [
{:author, :author, JSONAPI.SerializerTest.UserView, true},
{:best_comments, :best_comments, JSONAPI.SerializerTest.CommentView, true}
]
end

test "extrapolates relationship config with rewritten name" do
configs =
CommentaryView.relationships()
|> Enum.map(&Serializer.extrapolate_relationship_config/1)

assert configs == [
{:commenter, :user1, JSONAPI.SerializerTest.UserView, true},
{:witness, :user2, JSONAPI.SerializerTest.UserView, false},
{:user3, :user3, JSONAPI.SerializerTest.UserView, false}
]
end
end