Packages

Helper library to convert a map into a struct or a struct to a struct.

Current section

Files

Jump to
mapail lib maptu extension.ex
Raw

lib/maptu/extension.ex

defmodule Maptu.Extension do
@moduledoc """
Contains custom functions extending [Maptu](https://github.com/lexhide/maptu)
and superfluous to Maptu requirements. This module builds on top of `maptu.ex`
and with extracts and modifications of `maptu.ex`. Mapail would not
work without the additional functionality in this module.
Maptu Creators:
- [Andrea Leopardi](https://github.com/whatyouhide)
- [Aleksei Magusev](https://github.com/lexmag)
Maptu License:
- MIT
- https://github.com/lexhide/maptu/blob/master/LICENSE.txt
Modified by:
- Stephen Moloney
"""
import Kernel, except: [struct: 1, struct: 2]
@type non_strict_error_reason ::
:missing_struct_key
| :atom_key_not_expected
| {:bad_module_name, binary}
| {:non_existing_module, binary}
| {:non_struct, module}
@type strict_error_reason ::
non_strict_error_reason
| {:non_existing_atom, binary}
| {:non_existing_module, binary}
| {:unknown_struct_field, module, atom}
# We use a macro for this so we keep a nice stacktrace.
@doc :false
defmacro raise_on_error(code) do
quote do
case unquote(code) do
{:ok, result} -> result
{:ok, result, rest} -> rest
{:error, reason} -> raise(ArgumentError, Maptu.Extension.format_error(reason))
end
end
end
@doc """
Converts a map to a struct, silently capturing residual `key => value`
pairs into a map with keys in the `String.t` format.
`map` is a map with binary keys that represents a "dumped" struct; it must
contain a `"__struct__"` key with a binary value that can be converted to a
valid module name. If the value of `"__struct__"` is not a module name or it's
a module that isn't a struct, then an error is returned.
Keys in `map` that are not fields of the resulting struct are are collected along with their
respective values into a separate map denoted by `rest`.
This function returns `{:ok, struct, rest}` if the conversion is successful,
`{:error, reason}` otherwise.
## Examples
iex> Maptu.Extension.struct_rest(%{"__struct__" => "Elixir.URI", "port" => 8080, "foo" => 1})
{:ok, %URI{port: 8080}, %{"foo" => 1}}
iex> Maptu.Extension.struct_rest(%{"__struct__" => "Elixir.GenServer"})
{:error, {:non_struct, GenServer}}
"""
@spec struct_rest(map) :: {:ok, struct, map} | {:error, non_strict_error_reason}
def struct_rest(map) do
with {:ok, {mod_name, fields}} <- extract_mod_name_and_fields(map),
:ok <- ensure_exists(mod_name),
{:ok, mod} <- module_to_atom(mod_name),
do: struct_rest(mod, fields)
end
@doc """
Behaves like `Maptu.Extension.struct_rest/1` but returns the residual `rest` map rather
than the `struct` and raises in case of error.
This function behaves like `Maptu.Extension.struct_rest/1`, but it returns the `rest` map (instead
of `{:ok, struct, rest}`) if the conversion is valid, and raises an `ArgumentError`
exception if it's not valid.
## Examples
iex> Maptu.Extension.rest!(%{"__struct__" => "Elixir.URI", "port" => 8080})
%{}
iex> Maptu.Extension.rest!(%{"__struct__" => "Elixir.URI", "port" => 8080, "foo" => 1})
%{"foo" => 1}
iex> Maptu.Extension.rest!(%{"__struct__" => "Elixir.GenServer"})
** (ArgumentError) module is not a struct: GenServer
"""
@spec rest!(map) :: map | no_return
def rest!(map) do
map |> struct_rest() |> raise_on_error()
end
@doc """
Builds the `mod` struct with the given `fields`, silently capturing residual `key => value`
pairs into a map with keys in the `String.t` format.
This function takes a struct `mod` (`mod` should be a module that defines a
struct) and a map of fields with binary keys. It builds the `mod` struct by
safely parsing the fields in `fields`.
If a key in `fields` doesn't map to a field in the resulting struct, the key and it's
respective value are collected into a separate map denoted by `rest`.
This function returns `{:ok, struct, rest}` if the building is successful,
`{:error, reason}` otherwise.
## Examples
iex> Maptu.Extension.struct_rest(URI, %{"port" => 8080, "nonexisting_field" => 1})
{:ok, %URI{port: 8080}, %{"nonexisting_field" => 1}}
iex> Maptu.Extension.struct_rest(GenServer, %{})
{:error, {:non_struct, GenServer}}
"""
@spec struct_rest(module, map) :: {:ok, struct, map} | {:error, non_strict_error_reason}
def struct_rest(mod, fields) when is_atom(mod) and is_map(fields) do
with :ok <- ensure_exists(mod),
:ok <- ensure_struct(mod),
do: fill_struct_rest(mod, fields)
end
@doc """
Behaves like `Maptu.Extension.struct_rest/2` but returns the residual `rest` map rather than the `struct`
and raises in case of error.
This function behaves like `Maptu.Extension.struct_rest/2`, but it returns the `rest` map (instead
of `{:ok, struct, rest}`) if the conversion is valid, and raises an `ArgumentError`
exception if it's not valid.
## Examples
iex> Maptu.Extension.rest!(URI, %{"port" => 8080, "nonexisting_field" => 1})
%{"nonexisting_field" => 1}
iex> Maptu.Extension.rest!(GenServer, %{})
** (ArgumentError) module is not a struct: GenServer
"""
@spec rest!(module, map) :: map | no_return
def rest!(mod, fields) do
struct_rest(mod, fields) |> raise_on_error()
end
# Private or docless
defp extract_mod_name_and_fields(%{"__struct__" => "Elixir." <> _} = map),
do: {:ok, Map.pop(map, "__struct__")}
defp extract_mod_name_and_fields(%{"__struct__" => name}),
do: {:error, {:bad_module_name, name}}
defp extract_mod_name_and_fields(%{}),
do: {:error, :missing_struct_key}
defp module_to_atom("Elixir." <> name = mod_name) do
case to_existing_atom_safe(mod_name) do
{:ok, mod} -> {:ok, mod}
:error -> {:error, {:non_existing_module, name}}
end
end
defp ensure_exists(mod) when is_binary(mod) do
try do
String.to_existing_atom(mod)
rescue
ArgumentError ->
error_mod = Module.split(mod) |> Enum.join(".")
{:error, {:non_existing_module, error_mod}}
else
_atom -> :ok
end
end
defp ensure_exists(mod) when is_atom(mod) do
Atom.to_string(mod) |> ensure_exists()
end
defp ensure_struct(mod) when is_atom(mod) do
if function_exported?(mod, :__struct__, 0) do
:ok
else
{:error, {:non_struct, mod}}
end
end
defp fill_struct_rest(mod, fields) do
{result, rest} = Enum.reduce fields, {mod.__struct__(), %{}}, fn({bin_field, value}, {acc1, acc2}) ->
case to_existing_atom_safe(bin_field) do
{:ok, atom_field} ->
if Map.has_key?(acc1, atom_field), do: {Map.put(acc1, atom_field, value), acc2}, else: {acc1, Map.put(acc2, bin_field, value)}
:error ->
{acc1, Map.put(acc2, bin_field, value)}
end
end
{:ok, result, rest}
end
# @doc :false
# This function can be extended if want to allow atom keys
# def to_existing_atom_safe(arg) when is_atom(arg) do
# {:ok, arg}
# end
@doc :false
def to_existing_atom_safe(arg) when is_binary(arg) do
try do
String.to_existing_atom(arg)
rescue
ArgumentError -> :error
else
atom -> {:ok, atom}
end
end
@doc :false
def format_error(:missing_struct_key),
do: "the given map doesn't contain a \"__struct__\" key"
@doc :false
def format_error(:atom_key_not_expected),
do: "the map may contain an atom key which is not expected"
@doc :false
def format_error({:bad_module_name, name}) when is_binary(name),
do: "not an elixir module: #{inspect name}"
@doc :false
def format_error({:non_existing_module, mod}) when is_binary(mod),
do: "module doesn't exist: #{inspect mod}"
@doc :false
def format_error({:non_struct, mod}) when is_atom(mod),
do: "module is not a struct: #{inspect mod}"
@doc :false
def format_error({:non_existing_atom, bin}) when is_binary(bin),
do: "atom doesn't exist: #{inspect bin}"
@doc :false
def format_error({:unknown_struct_field, struct, field})
when is_atom(struct) and is_atom(field),
do: "unknown field #{inspect field} for struct #{inspect struct}"
end