Packages

A json api deserializer library for Elixir projects

Current section

Files

Jump to
jsonapi_deserializer lib json_api_deserializer.ex
Raw

lib/json_api_deserializer.ex

defmodule JsonApiDeserializer do
@moduledoc """
Json api deserializer able to deserialize json api documents with relationships.
For instance, this payload:
```
{
"data": [
{
"type": "posts",
"id": "13608770-76dd-47e5-a1c4-4d0d9c2483ad",
"links": {
"self": "http://link-to-post/1"
},
"attributes": {
"title": "First post",
"content": "First post content"
},
"relationships": {
"creator": {
"data": {
"type": "creators",
"id": "22208770-76dd-47e5-a1c4-4d0d9c2483ad"
},
"links": {
"related": "http://link-to-creator/1"
}
},
"comments": {
"links": {},
"data": [
{
"type": "comment",
"id": "22208770-76dd-47e5-a1c4-4d0d9c2483ab"
},
{
"type": "comment",
"id": "cb0759b0-03ab-4291-b067-84a9017fea6f"
}
]
}
}
},
{
"type": "posts",
"id": "13608770-76dd-47e5-a1c4-4d0d9c2483ae",
"links": {
"self": "http://link-to-post/2"
},
"attributes": {
"title": "Second post",
"content": "Second post content"
},
"relationships": {
"creator": {
"data": {
"type": "creators",
"id": "22208770-76dd-47e5-a1c4-4d0d9c2483ad"
},
"links": {
"related": "http://lnk-to-creator/1"
}
},
"comments": {
"links": {},
"data": [
{
"type": "comment",
"id": "22208770-76dd-47e5-a1c4-4d0d9c2483ac"
}
]
}
}
}
],
"included": [
{
"type": "creators",
"id": "22208770-76dd-47e5-a1c4-4d0d9c2483ad",
"attributes": {
"firstname": "John",
"lastname": "Doe"
},
"links": {
"self": "http://link-to-creator/1"
},
"relationships": {}
},
{
"type": "comment",
"id": "22208770-76dd-47e5-a1c4-4d0d9c2483ac",
"attributes": {
"content": "Comment 1 content",
"email": "john@doe.com"
},
"links": {
"self": "http://link-to-comment/1"
},
"relationships": {}
},
{
"type": "comment",
"id": "22208770-76dd-47e5-a1c4-4d0d9c2483ab",
"attributes": {
"content": "Comment 2 content",
"email": "john@doe.com"
},
"links": {
"self": "http://link-to-comment/2"
},
"relationships": {}
},
{
"type": "comment",
"id": "cb0759b0-03ab-4291-b067-84a9017fea6f",
"attributes": {
"content": "Comment 3 content",
"email": "john@doe.com"
},
"links": {
"self": "http://link-to-comment/3"
},
"relationships": {}
}
]
}
```
Will be deserialized in a map like this one:
```
[
%{
"__metadata" => %{
"links" => %{"self" => "http://link-to-post/1"},
"type" => "posts"
},
"comments" => [
%{
"__metadata" => %{
"links" => %{"self" => "http://link-to-comment/2"},
"type" => "comment"
},
"content" => "Comment 2 content",
"email" => "john@doe.com",
"id" => "22208770-76dd-47e5-a1c4-4d0d9c2483ab"
},
%{
"__metadata" => %{
"links" => %{"self" => "http://link-to-comment/3"},
"type" => "comment"
},
"content" => "Comment 3 content",
"email" => "john@doe.com",
"id" => "cb0759b0-03ab-4291-b067-84a9017fea6f"
}
],
"content" => "First post content",
"creator" => %{
"__metadata" => %{
"links" => %{"self" => "http://link-to-creator/1"},
"type" => "creators"
},
"firstname" => "John",
"id" => "22208770-76dd-47e5-a1c4-4d0d9c2483ad",
"lastname" => "Doe"
},
"id" => "13608770-76dd-47e5-a1c4-4d0d9c2483ad",
"title" => "First post"
},
%{
"__metadata" => %{
"links" => %{"self" => "http://link-to-post/2"},
"type" => "posts"
},
"comments" => [
%{
"__metadata" => %{
"links" => %{"self" => "http://link-to-comment/1"},
"type" => "comment"
},
"content" => "Comment 1 content",
"email" => "john@doe.com",
"id" => "22208770-76dd-47e5-a1c4-4d0d9c2483ac"
}
],
"content" => "Second post content",
"creator" => %{
"__metadata" => %{
"links" => %{"self" => "http://link-to-creator/1"},
"type" => "creators"
},
"firstname" => "John",
"id" => "22208770-76dd-47e5-a1c4-4d0d9c2483ad",
"lastname" => "Doe"
},
"id" => "13608770-76dd-47e5-a1c4-4d0d9c2483ae",
"title" => "Second post"
}
]
```
"""
@doc """
Deserialize a payload.
Payload can be a map or a string that will be decoded with `Jason`.
The return value is `{:ok, data}` with data beeing the decoded document as a list
or a map. Or `{:error, error}` if something went wrong when decoding.
Possible errors are:
* `Jason.DecodeError.t()` is an error from json decoding
* `:invalid_data` is when `"data"` is not a map or a list
* `:bad_relationships_type` is when `"relationships"` is not a map
* `:bad_included_type` is when `"included"` is not a list
* `:relationship_not_found` is when no relationship could be found for a specified type and id
* `:bad_relationship_data` is when the relationship data does not have an `id` or a `type` field
"""
@spec deserialize(binary() | map()) ::
{:ok, list() | map()}
| {:error, Jason.DecodeError.t()}
| {:error, :invalid_data}
| {:error, :bad_relationships_type}
| {:error, :bad_included_type}
| {:error, :relationship_not_found}
| {:error, :bad_relationship_data}
def deserialize(body) when is_binary(body) do
case Jason.decode(body) do
{:ok, data} ->
deserialize(data)
{:error, error} ->
{:error, error}
end
end
def deserialize(%{"data" => data} = body) when is_list(data) do
data
|> Enum.map(&deserialize(&1, Map.get(body, "included")))
|> Enum.reduce({:ok, []}, fn
{:ok, data}, {:ok, list} ->
{:ok, [data | list]}
{:ok, _data}, {:error, error} ->
{:error, error}
{:error, error}, _ ->
{:error, error}
end)
|> case do
{:ok, list} -> {:ok, Enum.reverse(list)}
{:error, error} -> {:error, error}
end
end
def deserialize(%{"data" => data} = body), do: deserialize(data, Map.get(body, "included"))
def deserialize(_), do: {:error, :invalid_data}
defp deserialize(nil, _), do: {:error, :invalid_data}
defp deserialize({:error, error}, _), do: {:error, error}
defp deserialize(data, included) do
case find_relationships(Map.get(data, "relationships", nil), included) do
{:ok, relationships} ->
{:ok,
data
|> Map.get("attributes", %{})
|> Map.merge(%{"id" => Map.get(data, "id", nil)})
|> Map.merge(%{
"__metadata" => %{
"type" => Map.get(data, "type", nil),
"links" => Map.get(data, "links", nil)
}
})
|> Map.merge(relationships)
|> JsonApiDeserializer.KeyFormatting.parse()}
{:error, error} ->
{:error, error}
end
end
defp find_relationships(nil, _), do: {:ok, %{}}
defp find_relationships(relationships, included)
when is_map(relationships) do
relationships
|> Map.to_list()
|> Enum.reduce({:ok, %{}}, fn
{key, value}, {:ok, map} ->
case find_relationship(value, included) do
{:ok, relationship} ->
{:ok, Map.put(map, key, relationship)}
{:error, error} ->
{:error, error}
end
_key, {:error, error} ->
{:error, error}
end)
end
defp find_relationships(_, _included),
do: {:error, :bad_relationships_type}
defp find_relationship(%{"data" => data}, included) when is_list(data) do
data
|> Enum.map(&find_relationship_in_included(&1, included))
|> Enum.reduce({:ok, []}, fn
{:ok, data}, {:ok, list} ->
{:ok, [data | list]}
{:ok, _data}, {:error, error} ->
{:error, error}
{:error, error}, _ ->
{:error, error}
end)
|> case do
{:ok, list} -> {:ok, Enum.reverse(list)}
{:error, error} -> {:error, error}
end
end
defp find_relationship(%{"data" => data}, included),
do: find_relationship_in_included(data, included)
defp find_relationship(%{}, _included), do: {:ok, nil}
defp find_relationship(_, _),
do: {:error, :bad_relationship_object}
defp find_relationship_in_included(%{"type" => type, "id" => id}, included)
when is_list(included) do
included
|> Enum.find({:error, :relationship_not_found}, &is_relationship(type, id, &1))
|> deserialize(included)
end
defp find_relationship_in_included(_, included) when is_list(included),
do: {:error, :bad_relationship_data}
defp find_relationship_in_included(_, _), do: {:error, :bad_included_type}
defp is_relationship(type, id, %{"type" => type_, "id" => id_})
when type == type_ and id == id_,
do: true
defp is_relationship(_, _, _), do: false
end