Current section
Files
Jump to
Current section
Files
lib/nestru.ex
defmodule Nestru do
@moduledoc "README.md"
|> File.read!()
|> String.split("[//]: # (Documentation)\n")
|> Enum.at(1)
|> String.trim("\n")
@doc """
Creates a nested struct from the given map.
The first argument is a map having key-value pairs. Supports both string
and atom keys in the map.
The second argument is a struct's module atom.
The third argument is a context value to be passed to implemented
functions of `Nestru.PreDecoder` and `Nestru.Decoder` protocols.
To give a hint on how to decode nested struct values or a list of such values
for the given field, implement `Nestru.Decoder` protocol for the struct.
Function calls `struct/2` to build the struct's value.
Keys in the map that don't exist in the struct are automatically discarded.
"""
def from_map(map, struct_module, context \\ [])
def from_map(%{} = map, struct_module, context) do
case prepare_map(:warn, map, struct_module, context) do
{:ok, nil} ->
{:ok, nil}
{:ok, map} ->
{:ok, struct(struct_module, map)}
{:error, %{} = map} ->
{:error, format_get_in_keys(map)}
{:invalid_hint_shape, %{message: {struct_module, value}} = error_map} ->
{:error, %{error_map | message: invalid_hint_shape(struct_module, value)}}
{:invalid_gather_fields_shape, struct_module, value} ->
{:error, %{message: invalid_gather_fields_shape(struct_module, value)}}
{:unexpected_item_value, key, value} ->
{:error, %{message: invalid_item_value(struct_module, key, value)}}
{:unexpected_item_function_return, key, fun, value} ->
{:error, %{message: invalid_item_function_return_value(struct_module, key, fun, value)}}
{:unexpected_atom_for_item_with_list, key, value} ->
{:error, %{message: invalid_atom_for_item_with_list(struct_module, key, value)}}
end
end
def from_map(map, _struct_module, _context) do
map
end
@doc """
Similar to `from_map/3` but checks if enforced struct's fields keys exist
in the given map.
Returns a struct or raises an error.
"""
def from_map!(map, struct_module, context \\ [])
def from_map!(%{} = map, struct_module, context) do
case prepare_map(:raise, map, struct_module, context) do
{:ok, nil} ->
nil
{:ok, map} ->
struct!(struct_module, map)
{:error, %{} = error_map} ->
raise format_raise_message("map", error_map)
{:invalid_hint_shape, %{message: {struct_module, value}}} ->
raise invalid_hint_shape(struct_module, value)
{:invalid_gather_fields_shape, struct_module, value} ->
raise invalid_gather_fields_shape(struct_module, value)
{:unexpected_item_value, key, value} ->
raise invalid_item_value(struct_module, key, value)
{:unexpected_item_function_return, key, fun, value} ->
raise invalid_item_function_return_value(struct_module, key, fun, value)
{:unexpected_atom_for_item_with_list, key, value} ->
raise invalid_atom_for_item_with_list(struct_module, key, value)
end
end
def from_map!(map, struct_module, _context) do
raise """
Can't shape #{inspect(struct_module)} because the given value \
is not a map but #{inspect(map)}.\
"""
end
defp prepare_map(error_mode, map, struct_module, context) do
struct_value = struct_module.__struct__()
struct_info = {struct_value, struct_module}
with {:ok, map} <- gather_fields_map(struct_info, map, context),
{:ok, decode_hint} <- get_decode_hint(struct_info, map, context),
{:ok, _shaped_fields} = ok <- shape_fields(error_mode, struct_info, decode_hint, map) do
ok
end
end
defp gather_fields_map(struct_info, map, context) do
{struct_value, struct_module} = struct_info
struct_value
|> Nestru.PreDecoder.gather_fields_map(context, map)
|> validate_fields_map(struct_module)
end
defp validate_fields_map({:ok, %{}} = ok, _struct_module),
do: ok
defp validate_fields_map({:error, message}, _struct_module),
do: {:error, %{message: message}}
defp validate_fields_map(value, struct_module),
do: {:invalid_gather_fields_shape, struct_module, value}
defp get_decode_hint(struct_info, map, context) do
{struct_value, struct_module} = struct_info
struct_value
|> Nestru.Decoder.from_map_hint(context, map)
|> validate_hint(struct_module)
end
defp validate_hint({:ok, hint} = ok, _struct_module) when is_nil(hint) or is_map(hint),
do: ok
defp validate_hint({:error, %{message: _}} = error, _struct_module),
do: error
defp validate_hint({:error, message}, _struct_module),
do: {:error, %{message: message}}
defp validate_hint(value, struct_module),
do: {:invalid_hint_shape, %{message: {struct_module, value}}}
defp shape_fields(_error_mode, _struct_info, nil = _decode_hint, _map) do
{:ok, nil}
end
defp shape_fields(error_mode, struct_info, decode_hint, map) do
{struct_value, struct_module} = struct_info
struct_keys = struct_value |> Map.keys() |> List.delete(:__struct__)
inform_unknown_keys(error_mode, decode_hint, struct_module, struct_keys)
decode_hint = Map.take(decode_hint, struct_keys)
kvi = decode_hint |> :maps.iterator() |> :maps.next()
with {:ok, acc} <- shape_fields_recursively(error_mode, kvi, map) do
as_is_keys = struct_keys -- Map.keys(decode_hint)
fields =
Enum.reduce(as_is_keys, %{}, fn key, taken_map ->
if has_field_value?(map, key) do
value = get_field_value(map, key)
Map.put(taken_map, key, value)
else
taken_map
end
end)
{:ok, Map.merge(fields, acc)}
end
end
defp inform_unknown_keys(error_mode, map, struct_module, struct_keys) do
if extra_key = List.first(Map.keys(map) -- struct_keys) do
message = """
The decoding hint value for key #{inspect(extra_key)} received from Nestru.Decoder.from_map_hint/3 \
implemented for #{inspect(struct_module)} is unexpected because the struct hasn't a field with such key name.\
"""
if error_mode == :raise do
raise message
else
IO.warn(message)
end
end
:ok
end
defp shape_fields_recursively(error_mode, kvi, map, acc \\ %{})
defp shape_fields_recursively(_error_mode, :none = _kvi, _map, target_map) do
{:ok, target_map}
end
defp shape_fields_recursively(error_mode, {key, fun, iterator}, map, target_map)
when is_function(fun) do
map_value = get_field_value(map, key)
case fun.(map_value) do
{:ok, updated_value} ->
target_map = Map.put(target_map, key, updated_value)
shape_fields_recursively(error_mode, :maps.next(iterator), map, target_map)
{:error, %{message: _, path: _} = error_map} ->
{:error, insert_to_path(error_map, key)}
{:error, message} ->
{:error, insert_to_path(%{message: message}, key)}
value ->
{:unexpected_item_function_return, key, fun, value}
end
end
defp shape_fields_recursively(error_mode, {key, module, iterator}, map, target_map)
when is_atom(module) do
if function_exported?(module, :__struct__, 0) do
result =
case get_field_value(map, key) do
[_ | _] ->
{:unexpected_atom_for_item_with_list, key, module}
nil ->
{:ok, nil}
map_value ->
shape_nested_struct(error_mode, key, map_value, module)
end
case result do
{:ok, shaped_value} ->
target_map = Map.put(target_map, key, shaped_value)
shape_fields_recursively(error_mode, :maps.next(iterator), map, target_map)
error ->
error
end
else
{:unexpected_item_value, key, module}
end
end
defp shape_fields_recursively(_error_mode, kvi, _map, _acc) do
{key, value, _iterator} = kvi
{:unexpected_item_value, key, value}
end
defp shape_nested_struct(error_mode, key, map_value, module) do
shaped_value =
if error_mode == :raise do
from_map!(map_value, module)
else
from_map(map_value, module)
end
case shaped_value do
struct when error_mode == :raise ->
{:ok, struct}
{:ok, _struct} = ok ->
ok
{:error, error_map} ->
{:error, insert_to_path(error_map, key)}
end
end
defp insert_to_path(error_map, key) do
path =
Enum.concat([
List.wrap(key),
Map.get(error_map, :path, [])
])
Map.put(error_map, :path, path)
end
defp has_field_value?(map, key) do
Map.has_key?(map, to_string(key)) or Map.has_key?(map, key)
end
defp get_field_value(map, key) do
Map.get(map, to_string(key)) || Map.get(map, key)
end
defp invalid_gather_fields_shape(struct_module, value) do
"""
Expected a {:ok, map} | {:error, term} value from Nestru.PreDecoder.gather_fields_map/3 \
function implemented for #{inspect(struct_module)}, received #{inspect(value)} instead.\
"""
end
defp invalid_hint_shape(struct_module, value) do
"""
Expected a {:ok, nil | map} | {:error, term} value from Nestru.Decoder.from_map_hint/3 \
function implemented for #{inspect(struct_module)}, received #{inspect(value)} instead.\
"""
end
defp invalid_item_function_return_value(struct_module, key, fun, value) do
"""
Expected {:ok, term}, {:error, %{message: term, path: list}}, or %{:error, term} \
return value from the anonymous function for the key defined in the following \
{:ok, %{#{inspect(key)} => #{inspect(fun)}}} tuple returned from Nestru.Decoder.from_map_hint/3 \
function implemented for #{inspect(struct_module)}, received #{inspect(value)} instead.\
"""
end
defp invalid_item_value(struct_module, key, value) do
"""
Expected a struct's module atom or a function value for #{inspect(key)} key received \
from Nestru.Decoder.from_map_hint/3 function implemented for #{inspect(struct_module)}, \
received #{inspect(value)} instead.\
"""
end
defp invalid_atom_for_item_with_list(struct_module, key, value) do
"""
Unexpected #{inspect(value)} value received for #{inspect(key)} key \
from Nestru.Decoder.from_map_hint/3 function implemented for #{inspect(struct_module)}. \
You can return &Nestru.from_list_of_maps(&1, #{inspect(value)}) as a hint \
for list decoding.\
"""
end
@doc """
Creates a map from the given nested struct.
Casts each field's value to a map recursively, whether it is a struct or
a list of structs.
To give a hint to the function of how to generate a map, implement
`Nestru.Encoder` protocol for the struct. That can be used to keep
additional type information for the field that can have a value of various
struct types.
"""
def to_map(struct) do
case cast_to_map(struct) do
{:invalid_hint_shape, %{message: {struct_module, value}} = error_map} ->
{:error, %{error_map | message: invalid_to_map_value_message(struct_module, value)}}
{:ok, _value} = ok ->
ok
{:error, map} ->
{:error, format_get_in_keys(map)}
end
end
@doc """
Similar to `to_map/1`.
Returns a map or raises an error.
"""
def to_map!(struct) do
case cast_to_map(struct) do
{:ok, map} ->
map
{:invalid_hint_shape, %{message: {struct_module, value}}} ->
raise invalid_to_map_value_message(struct_module, value)
{:error, error_map} ->
raise format_raise_message("struct", error_map)
end
end
defp cast_to_map(struct, kvi \\ nil, acc \\ {[], %{}})
defp cast_to_map(%module{} = struct, _kvi, {path, _target_map} = acc) do
case struct |> Nestru.Encoder.to_map() |> validate_hint(module) do
{:ok, map} -> cast_to_map(map, nil, acc)
{tag, %{} = map} -> {tag, Map.put(map, :path, path)}
end
end
defp cast_to_map([_ | _] = list, _kvi, {path, _target_map} = _acc) do
list
|> reduce_via_cast_to_map(path)
|> maybe_ok_reverse()
end
defp cast_to_map(value, _kvi, _acc) when not is_map(value) do
{:ok, value}
end
defp cast_to_map(map, nil, acc) do
kvi =
map
|> :maps.iterator()
|> :maps.next()
cast_to_map(map, kvi, acc)
end
defp cast_to_map(_map, :none, {_path, target_map} = _acc) do
{:ok, target_map}
end
defp cast_to_map(map, {key, value, iterator}, {path, target_map}) do
with {:ok, casted_value} <- cast_to_map(value, nil, {[key | path], %{}}) do
target_map = Map.put(target_map, key, casted_value)
kvi = :maps.next(iterator)
cast_to_map(map, kvi, {path, target_map})
end
end
defp reduce_via_cast_to_map(list, path) do
list
|> Enum.with_index()
|> Enum.reduce_while([], fn {item, idx}, acc ->
case cast_to_map(item, nil, {[], %{}}) do
{:ok, casted_item} ->
{:cont, [casted_item | acc]}
{:error, error_map} ->
keys_list =
path
|> Enum.reverse()
|> Enum.concat([idx])
{:halt, {:error, insert_to_path(error_map, keys_list)}}
end
end)
end
defp maybe_ok_reverse([_ | _] = list), do: {:ok, Enum.reverse(list)}
defp maybe_ok_reverse([]), do: {:ok, []}
defp maybe_ok_reverse({:error, _map} = error), do: error
defp invalid_to_map_value_message(struct_module, value) do
"""
Expected a {:ok, nil | map} | {:error, term} value from Nestru.Encoder.to_map/1 \
function implemented for #{inspect(struct_module)}, received #{inspect(value)} instead.\
"""
end
defp format_get_in_keys(map) do
keys =
map
|> Map.get(:path, [])
|> Enum.map(&to_access_fun/1)
Map.put(map, :get_in_keys, keys)
end
defp to_access_fun(key) when is_atom(key), do: Access.key!(key)
defp to_access_fun(key) when is_integer(key), do: Access.at!(key)
defp to_access_fun(key) when is_function(key), do: key
defp format_raise_message(object, map) do
keys =
map
|> Map.get(:path, [])
|> Enum.map(&to_access_string/1)
|> Enum.join(", ")
"""
#{stringify(map.message)}
See details by calling get_in/2 with the #{object} and the following keys: [#{keys}]\
"""
end
defp to_access_string(key) when is_atom(key), do: "Access.key!(#{inspect(key)})"
defp to_access_string(key) when is_integer(key), do: "Access.at!(#{key})"
defp to_access_string(key) when is_binary(key), do: key
defp stringify(value) when is_binary(value), do: value
defp stringify(value), do: inspect(value)
@doc """
Creates a list of nested structs from the given list of maps.
The first argument is a list of maps.
If the second argument is a struct's module atom, then the function calls
the `from_map/3` on each input list item.
If the second argument is a list of struct module atoms, the function
calls the `from_map/3` function on each input list item with the module atom
taken at the same index of the second list.
In this case, both arguments should be of equal length.
The third argument is a context value to be passed to implemented
functions of `Nestru.PreDecoder` and `Nestru.Decoder` protocols.
The function returns a list of structs or the first error from `from_map/3`
function.
"""
def from_list_of_maps(list, struct_atoms, context \\ [])
def from_list_of_maps([_ | _] = list, struct_atoms, context) do
list
|> reduce_via_from_map(struct_atoms, context)
|> maybe_ok_reverse()
end
def from_list_of_maps(list, _struct_atoms, _context) do
{:ok, list}
end
@doc """
Similar to `from_list_of_maps/2` but checks if enforced struct's fields keys
exist in the given maps.
Returns a struct or raises an error.
"""
def from_list_of_maps!(list, struct_atoms, context \\ [])
def from_list_of_maps!([_ | _] = list, struct_atoms, context) do
case list |> reduce_via_from_map(struct_atoms, context) |> maybe_ok_reverse() do
{:ok, list} -> list
{:error, %{message: message}} -> raise message
end
end
def from_list_of_maps!(list, _struct_atoms, _context) do
list
end
defp reduce_via_from_map(list, [_ | _] = struct_atoms, context)
when length(list) == length(struct_atoms) do
list
|> Enum.with_index()
|> Enum.reduce_while([], fn {item, idx}, acc ->
struct_module = Enum.at(struct_atoms, idx)
case from_map(item, struct_module, context) do
{:ok, casted_item} ->
{:cont, [casted_item | acc]}
{:error, map} ->
{:halt, {:error, insert_to_path(map, idx)}}
end
end)
end
defp reduce_via_from_map(list, struct_atoms, context) when is_atom(struct_atoms) do
list
|> Enum.with_index()
|> Enum.reduce_while([], fn {item, idx}, acc ->
case from_map(item, struct_atoms, context) do
{:ok, casted_item} ->
{:cont, [casted_item | acc]}
{:error, map} ->
{:halt, {:error, insert_to_path(map, idx)}}
end
end)
end
defp reduce_via_from_map(list, struct_atoms, _context) do
{:error,
%{
message: """
The map's list length (#{length(list)}) is expected to be equal to \
the struct module atoms list length (#{length(struct_atoms)}).\
"""
}}
end
end