Current section

Files

Jump to
exhal lib exhal transcoder.ex
Raw

lib/exhal/transcoder.ex

defmodule ExHal.Transcoder do
@moduledoc """
Helps to build transcoders for HAL documents.
Given a document like
```json
{
"name": "Jane Doe",
"mailingAddress": "123 Main St",
"_links": {
"app:department": { "href": "http://example.com/dept/42" },
"app:manager": { "href": "http://example.com/people/84" }
}
}
```
We can define an transcoder for it.
```elixir
defmodule PersonTranscoder do
use ExHal.Transcoder
defproperty "name"
defproperty "mailingAddress", param: :address
deflink "app:department", param: :department_url
deflink "app:manager", param: :manager_id, value_converter: PersonUrlConverter
end
```
`PersonUrlConverter` is a module that has adopted the `ExHal.ValueConverter` behavior.
```elixir
defmodule PersonUrlConverter do
@behaviour ExHal.ValueConveter
def from_hal(person_url) do
to_string(person_url)
|> String.split("/")
|> List.last
end
def to_hal(person_id) do
"http://example.com/people/\#{person_id}"
end
end
```
We can use this transcoder to to extract the pertinent parts of the document into a map.
```elixir
iex> PersonTranscoder.decode!(doc)
%{name: "Jane Doe",
address: "123 Main St",
department_url: "http://example.com/dept/42",
manager_id: 84}
```
iex> PersonTranscoder.encode!(%{name: "Jane Doe",
address: "123 Main St",
department_url: "http://example.com/dept/42",
manager_id: 84})
~s(
{
"name": "Jane Doe",
"mailingAddress": "123 Main St",
"_links": {
"app:department": { "href": "http://example.com/dept/42" },
"app:manager": { "href": "http://example.com/people/84" }
}
} )
```
"""
@type t :: module
@doc """
Returns a decoded version of HAL document merged with the initial params.
initial_params - the initial params with which the newly extracted info should
merged.
src_doc - the document to interpret
opts - options for use by modules adopting ExHal.ValueConverterWithOptions behaviour
"""
@callback decode!(ExHal.Document.t()) :: %{}
@callback decode!(%{}, ExHal.Document.t()) :: %{}
@callback decode!(ExHal.Document.t(), keyword) :: %{}
@callback decode!(%{}, ExHal.Document.t(), keyword) :: %{}
@doc """
Returns an HAL version of params provided, combined with the initial doc.
initial_doc - the initial document with which the newly encoded info should
merged.
src_params - the params to encoded into HAL
opts - options for use by modules adopting ExHal.ValueConverterWithOptions behaviour
"""
@callback encode!(%{}) :: ExHal.Document.t()
@callback encode!(ExHal.Document.t(), %{}) :: ExHal.Document.t()
@callback encode!(%{}, keyword) :: ExHal.Document.t()
@callback encode!(ExHal.Document.t(), %{}, keyword) :: ExHal.Document.t()
@doc """
Updates an existing object, such as one created by ExHal.Transcoder.decode!
initial_object - a map containing properties, links etc
patch_ops - a list of JSON-patch operations (https://tools.ietf.org/html/rfc6902)
Supported are "replace" for properties, links, and collections of links
and "add" for collections of links. For example:
[ %{"op => "replace", "path" => "/panicLevel", "value" => 42}, # replace a property
%{"op => "replace, "path" => "/_links/answers", "value" => [ "urn:earth", "urn:universe"]} # replace link collection
%{"op => "add", "path" => "/_links/answers/-", "value" => "urn:everyhing"}] # append to link collection
"""
@callback patch!(%{}, [%{}]) :: %{}
@callback patch!(%{}, [%{}], keyword) :: %{}
defmacro __using__(_opts) do
quote do
import unquote(__MODULE__)
Module.register_attribute(__MODULE__, :extractors, accumulate: true, persist: false)
Module.register_attribute(__MODULE__, :injectors, accumulate: true, persist: false)
Module.register_attribute(__MODULE__, :patchers, accumulate: true, persist: false)
@before_compile unquote(__MODULE__)
end
end
defmacro __before_compile__(_env) do
quote do
@behaviour ExHal.Transcoder
def decode!(initial_params, %ExHal.Document{} = doc, opts) do
@extractors
|> Enum.reduce(initial_params, &apply(__MODULE__, &1, [doc, &2, opts]))
end
def decode!(doc, [_ | _] = opts), do: decode!(%{}, doc, opts)
def decode!(initial_params, %ExHal.Document{} = doc), do: decode!(initial_params, doc, [])
def decode!(doc), do: decode!(%{}, doc, [])
def encode!(%ExHal.Document{} = initial_doc, params, opts) do
@injectors
|> Enum.reduce(initial_doc, &apply(__MODULE__, &1, [&2, params, opts]))
end
def encode!(params, [_ | _] = opts), do: encode!(%ExHal.Document{}, params, opts)
def encode!(%ExHal.Document{} = initial_doc, params), do: encode!(initial_doc, params, [])
def encode!(params), do: encode!(%ExHal.Document{}, params, [])
def patch!(initial_object, patch_ops), do: patch!(initial_object, patch_ops, [])
def patch!(initial_object, [], _opts), do: initial_object
def patch!(initial_object, [patch_op | remaining_ops], opts) do
@patchers
|> Enum.reduce(initial_object, &apply(__MODULE__, &1, [&2, patch_op, [opts]]))
|> patch!(remaining_ops, opts)
end
end
end
defmodule ValueConverter do
@type t :: module
@doc """
Returns Elixir representation of HAL value.
hal_value - The HAL representation of the value to convert.
"""
@callback from_hal(any) :: any
@doc """
Returns HAL representation of Elixir value.
elixir_value - The Elixir representation of the value to convert.
"""
@callback to_hal(any) :: any
end
defmodule ValueConverterWithOptions do
@type t :: module
@doc """
Returns Elixir representation of HAL value.
hal_value - The HAL representation of the value to convert.
opts - Options to be used by the converter.
"""
@callback from_hal(any, keyword) :: any
@doc """
Returns HAL representation of Elixir value.
elixir_value - The Elixir representation of the value to convert.
opts - Options to be used by the converter.
"""
@callback to_hal(any, keyword) :: any
end
defmodule IdentityConverter do
@behaviour ValueConverter
def from_hal(it), do: it
def to_hal(it), do: it
end
defp interpret_opts(options, name) do
unique_string = (:rand.uniform() * 100_000_000) |> trunc |> Integer.to_string()
param_names = options |> Keyword.get(:param, String.to_atom(name)) |> List.wrap()
templated = options |> Keyword.get(:templated, false)
value_converter = Keyword.get(options, :value_converter, IdentityConverter)
extractor_name = :"extract_#{unique_string}_#{Enum.join(param_names, ".")}"
injector_name = :"inject_#{unique_string}_#{Enum.join(param_names, ".")}"
patcher_name = :"patch_#{unique_string}_#{Enum.join(param_names, ".")}"
{param_names, value_converter, extractor_name, injector_name, patcher_name, templated}
end
@doc """
Define a property extractor and injector.
* name - the name of the property in HAL
* options - Keywords arguments
- :param - the key(s) in the param structure that map to this property. Default is `String.to_atom(name)`.
- :value_converter - a `ExHal.Transcoder.ValueConverter` with which to convert the value to and from HAL
"""
defmacro defproperty(name, options \\ []) do
{param_names, value_converter, extractor_name, injector_name, patcher_name, _} =
interpret_opts(options, name)
quote do
def unquote(extractor_name)(doc, params, opts) do
ExHal.get_lazy(doc, unquote(name), fn -> nil end)
|> decode_value(unquote(value_converter), opts)
|> put_param(params, unquote(param_names))
end
@extractors unquote(extractor_name)
def unquote(injector_name)(doc, params, opts) do
get_in(params, unquote(param_names))
|> encode_value(unquote(value_converter), opts)
|> put_property(doc, unquote(name))
end
@injectors unquote(injector_name)
unquote do
unless(Keyword.get(options, :protected)) do
quote do
def unquote(patcher_name)(
obj,
%{"op" => "replace", "path" => "/#{unquote(name)}", "value" => value},
opts
) do
value
|> decode_value(unquote(value_converter), opts)
|> put_param(obj, unquote(param_names))
end
end
end
end
def unquote(patcher_name)(obj, _patch_op, _opts), do: obj
@patchers unquote(patcher_name)
end
end
@doc """
Define a link extractor & injector.
* rel - the rel of the link in HAL
* options - Keywords arguments
- :param - the key(s) in the param structure that maps to this link. Required.
- :templated - a boolean that adds a `templated: true` parameter if true
- :value_converter - a `ExHal.Transcoder.ValueConverter` with which to convert the link target when en/decoding HAL
"""
defmacro deflink(rel, options \\ []) do
{param_names, value_converter, extractor_name, injector_name, patcher_name, templated} =
interpret_opts(options, rel)
quote do
def unquote(extractor_name)(doc, params, opts) do
ExHal.get_links_lazy(doc, unquote(rel), fn -> [] end)
|> Enum.map(&Map.get(&1, :href))
|> List.first()
|> decode_value(unquote(value_converter), opts)
|> put_param(params, unquote(param_names))
end
@extractors unquote(extractor_name)
def unquote(injector_name)(doc, params, opts) do
get_in(params, unquote(param_names))
|> encode_value(unquote(value_converter), opts)
|> put_link(doc, unquote(rel), unquote(templated))
end
@injectors unquote(injector_name)
unquote do
unless(Keyword.get(options, :protected)) do
quote do
def unquote(patcher_name)(
obj,
%{
"op" => "replace",
"path" => "/_links/#{unquote(rel)}",
"value" => %{"href" => href}
},
opts
) do
href
|> decode_value(unquote(value_converter), opts)
|> put_param(obj, unquote(param_names))
end
end
end
end
def unquote(patcher_name)(obj, _patch_op, _opts), do: obj
@patchers unquote(patcher_name)
end
end
@doc """
Define a link extractor & injector for links that may have more than one item.
* rel - the rel of the link in HAL
* options - Keywords arguments
- :param - the key(s) in the param structure that maps to this link. Required.
- :value_converter - a `ExHal.Transcoder.ValueConverter` with which to convert the link target when en/decoding HAL
"""
defmacro deflinks(rel, options \\ []) do
{param_names, value_converter, extractor_name, injector_name, patcher_name, _} =
interpret_opts(options, rel)
quote do
def unquote(extractor_name)(doc, params, opts) do
ExHal.link_targets_lazy(doc, unquote(rel), fn -> nil end)
|> decode_value(unquote(value_converter), opts)
|> put_param(params, unquote(param_names))
end
@extractors unquote(extractor_name)
def unquote(injector_name)(doc, params, opts) do
get_in(params, unquote(param_names))
|> encode_value(unquote(value_converter), opts)
|> Enum.reduce(doc, &put_link(&1, &2, unquote(rel)))
end
@injectors unquote(injector_name)
unquote do
unless(Keyword.get(options, :protected)) do
quote do
def unquote(patcher_name)(
obj,
%{
"op" => "add",
"path" => "/_links/#{unquote(rel)}/-",
"value" => %{"href" => href}
},
opts
) do
update_in(obj, unquote(param_names), fn
nil -> [href]
links -> [href | links]
end)
end
def unquote(patcher_name)(
obj,
%{"op" => "replace", "path" => "/_links/#{unquote(rel)}", "value" => links},
opts
) do
hrefs =
links
|> List.wrap()
|> Enum.map(&Map.get(&1, "href"))
update_in(obj, unquote(param_names), fn _ -> hrefs end)
end
end
end
end
def unquote(patcher_name)(obj, _patch_op, _opts), do: obj
@patchers unquote(patcher_name)
end
end
def decode_value(nil, _opts), do: nil
def decode_value(raw_value, converter, opts) do
if :erlang.function_exported(converter, :from_hal, 2) do
converter.from_hal(raw_value, opts)
else
converter.from_hal(raw_value)
end
end
def put_param(nil, params, _), do: params
def put_param(value, params, param_names) do
params = build_out_containers(params, param_names)
put_in(params, param_names, value)
end
def encode_value(nil, _, _opts), do: nil
def encode_value(raw_value, converter, opts) do
if :erlang.function_exported(converter, :to_hal, 2) do
converter.to_hal(raw_value, opts)
else
converter.to_hal(raw_value)
end
end
def put_link(nil, doc, _), do: doc
def put_link(target, doc, rel, templated \\ false)
def put_link(nil, doc, _, _), do: doc
def put_link(target, doc, rel, templated) do
ExHal.Document.put_link(doc, rel, target, templated)
end
def put_property(nil, doc, _), do: doc
def put_property(value, doc, prop_name) do
ExHal.Document.put_property(doc, prop_name, value)
end
defp build_out_containers(params, [_h] = _param_names), do: params
defp build_out_containers(params, param_names) do
1..(Enum.count(param_names) - 1)
|> Enum.map(&Enum.take(param_names, &1))
|> Enum.reduce(params, fn c, acc ->
case get_in(acc, c) do
nil -> put_in(acc, c, %{})
_ -> acc
end
end)
end
end