Packages

Tiny library to convert from "encoded" maps to Elixir structs.

Current section

Files

Jump to
maptu lib maptu.ex
Raw

lib/maptu.ex

defmodule Maptu do
@moduledoc """
Provides functions to convert from "dumped" maps to Elixir structs.
This module provides functions to safely convert maps (with string keys) that
represent structs (usually decoded from some kind of protocol, like
MessagePack or JSON) to Elixir structs.
## Rationale
Many Elixir libraries need to encode and decode maps as well as
structs. Encoding is often straightforward (think of libraries like
[poison][gh-poison] or [msgpax][gh-msgpax]): the map is encoded by converting
keys to strings and encoding values recursively. This works natively with
structs as well, since structs are just maps with anadditional `:__struct__`
key.
The problem arises when such structs have to be decoded back to Elixir terms:
decoding will often result in a map with string keys (as the information about
keys being atoms were lost in the encoding), including a `"__struct__"` key.
Trying to blindly convert all these string keys to atoms and building a struct
by reading the `:__struct__` key is dangerous: converting dynamic input to
atoms is one of the most frequent culprit of memory leaks in Erlang
applications. This is where `Maptu` becomes useful: it provides functions that
safely convert from this kind of maps to Elixir structs.
## Use case
Let's pretend we're writing a JSON encoder/decoder for Elixir. We have
generic encoder/decoder that work for all kinds of maps:
def encode(map) when is_map(map) do
# some binary is built here
end
def decode(bin) when is_binary(bin) do
# decoding happens here
end
When we encode and then decode a struct, something like this is likely to happen:
%URI{port: 8080} |> encode() |> decode()
#=> %{"__struct__" => "Elixir.URI", "port" => 8080}
To properly decode the struct back to a `%URI{}` struct, we would have to
check the value of `"__struct__"` (check that it's an existing atom and then
an existing module), then check each key-value pair in the map to see if it's
a field of the `URI` struct and so on. `Maptu` does exactly this!
%URI{port: 8080} |> encode() |> decode() |> Maptu.struct!()
#=> %URI{port: 8080}
This is just one use case `Maptu` is good at; read the documentation for the
provided functions for more information on the capabilities of this library.
[gh-poison]: https://github.com/devinus/poison
[gh-msgpax]: https://github.com/lexmag/msgpax
"""
import Kernel, except: [struct: 1, struct: 2]
@type non_strict_error_reason ::
:missing_struct_key
| {:bad_module_name, binary}
| {:non_existing_module, binary}
| {:non_struct, module}
@type strict_error_reason ::
non_strict_error_reason
| {:non_existing_atom, binary}
| {:unknown_struct_field, module, atom}
# We use a macro for this so we keep a nice stacktrace.
defmacrop raise_on_error(code) do
quote do
case unquote(code) do
{:ok, result} -> result
{:error, reason} -> raise ArgumentError, format_error(reason)
end
end
end
@doc """
Converts a map to a struct, silently ignoring erroneous keys.
`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 simply
discarded.
This function returns `{:ok, struct}` if the conversion is successful,
`{:error, reason}` otherwise.
## Examples
iex> Maptu.struct(%{"__struct__" => "Elixir.URI", "port" => 8080, "foo" => 1})
{:ok, %URI{port: 8080}}
iex> Maptu.struct(%{"__struct__" => "Elixir.GenServer"})
{:error, {:non_struct, GenServer}}
"""
@spec struct(%{}) :: {:ok, %{}} | {:error, non_strict_error_reason}
def struct(map) do
with {:ok, {mod_name, fields}} <- extract_mod_name_and_fields(map),
{:ok, mod} <- module_to_atom(mod_name),
do: struct(mod, fields)
end
@doc """
Converts a map to a struct, failing on erroneous keys.
This function behaves like `Maptu.struct/1`, except that it returns an error
if one of the fields in `map` isn't a field of the resulting struct.
This function returns `{:ok, struct}` if the conversion is successful,
`{:error, reason}` otherwise.
## Examples
iex> Maptu.strict_struct(%{"__struct__" => "Elixir.URI", "port" => 8080})
{:ok, %URI{port: 8080}}
iex> Maptu.strict_struct(%{"__struct__" => "Elixir.URI", "pid" => 1})
{:error, {:unknown_struct_field, URI, :pid}}
"""
@spec strict_struct(%{}) :: {:ok, %{}} | {:error, strict_error_reason}
def strict_struct(map) do
with {:ok, {mod_name, fields}} <- extract_mod_name_and_fields(map),
{:ok, mod} <- module_to_atom(mod_name),
do: strict_struct(mod, fields)
end
@doc """
Behaves like `Maptu.struct/1` but raises in case of error.
This function behaves like `Maptu.struct/1`, but it returns `struct` (instead
of `{:ok, struct}`) if the conversion is valid, and raises an `ArgumentError`
exception if it's not valid.
## Examples
iex> Maptu.struct!(%{"__struct__" => "Elixir.URI", "port" => 8080})
%URI{port: 8080}
iex> Maptu.struct!(%{"__struct__" => "Elixir.GenServer"})
** (ArgumentError) module is not a struct: GenServer
"""
@spec struct!(%{}) :: %{} | no_return
def struct!(map) do
map |> struct() |> raise_on_error()
end
@doc """
Behaves like `Maptu.strict_struct/1` but raises in case of error.
This function behaves like `Maptu.strict_struct/1`, but it returns `struct`
(instead of `{:ok, struct}`) if the conversion is valid, and raises an
`ArgumentError` exception if it's not valid.
## Examples
iex> Maptu.strict_struct!(%{"__struct__" => "Elixir.URI", "port" => 8080})
%URI{port: 8080}
iex> Maptu.strict_struct!(%{"__struct__" => "Elixir.URI", "pid" => 1})
** (ArgumentError) unknown field :pid for struct URI
"""
@spec strict_struct!(%{}) :: %{} | no_return
def strict_struct!(map) do
map |> strict_struct() |> raise_on_error()
end
@doc """
Builds the `mod` struct with the given `fields`, silently ignoring erroneous
keys.
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, it's
ignored.
This function returns `{:ok, struct}` if the building is successful,
`{:error, reason}` otherwise.
## Examples
iex> Maptu.struct(URI, %{"port" => 8080, "nonexisting_field" => 1})
{:ok, %URI{port: 8080}}
iex> Maptu.struct(GenServer, %{})
{:error, {:non_struct, GenServer}}
"""
@spec struct(module, %{}) :: {:ok, %{}} | {:error, non_strict_error_reason}
def struct(mod, fields) when is_atom(mod) and is_map(fields) do
with :ok <- ensure_struct(mod), do: fill_struct(mod, fields)
end
@doc """
Builds the `mod` struct with the given `fields`, failing on erroneous keys.
This function behaves like `Maptu.strict_struct/2`, except it returns an error
when keys in `fields` don't map to fields in the resulting struct.
This function returns `{:ok, struct}` if the building is successful,
`{:error, reason}` otherwise.
## Examples
iex> Maptu.strict_struct(URI, %{"port" => 8080})
{:ok, %URI{port: 8080}}
iex> Maptu.strict_struct(URI, %{"pid" => 1})
{:error, {:unknown_struct_field, URI, :pid}}
"""
@spec strict_struct(module, %{}) :: {:ok, %{}} | {:error, strict_error_reason}
def strict_struct(mod, fields) when is_atom(mod) and is_map(fields) do
with :ok <- ensure_struct(mod), do: strict_fill_struct(mod, fields)
end
@doc """
Behaves like `Maptu.struct/2` but raises in case of error.
This function behaves like `Maptu.struct/2`, but it returns `struct` (instead
of `{:ok, struct}`) if the conversion is valid, and raises an `ArgumentError`
exception if it's not valid.
## Examples
iex> Maptu.struct!(URI, %{"port" => 8080})
%URI{port: 8080}
iex> Maptu.struct!(GenServer, %{})
** (ArgumentError) module is not a struct: GenServer
"""
@spec struct!(module, %{}) :: %{} | no_return
def struct!(mod, fields) do
struct(mod, fields) |> raise_on_error()
end
@doc """
Behaves like `Maptu.strict_struct/2` but raises in case of error.
This function behaves like `Maptu.strict_struct/2`, but it returns `struct`
(instead of `{:ok, struct}`) if the conversion is valid, and raises an
`ArgumentError` exception if it's not valid.
## Examples
iex> Maptu.strict_struct!(URI, %{"port" => 8080})
%URI{port: 8080}
iex> Maptu.strict_struct!(URI, %{"pid" => 1})
** (ArgumentError) unknown field :pid for struct URI
"""
@spec strict_struct!(module, %{}) :: %{} | no_return
def strict_struct!(mod, fields) do
strict_struct(mod, fields) |> raise_on_error()
end
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_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(mod, fields) do
result = Enum.reduce fields, mod.__struct__(), fn({field, value}, acc) ->
case to_existing_atom_safe(field) do
{:ok, field} ->
if Map.has_key?(acc, field), do: Map.put(acc, field, value), else: acc
:error ->
acc
end
end
{:ok, result}
end
defp strict_fill_struct(mod, fields) do
try do
result = Enum.reduce fields, mod.__struct__(), fn({field, value}, acc) ->
case to_existing_atom_safe(field) do
{:ok, field} ->
if Map.has_key?(acc, field) do
Map.put(acc, field, value)
else
throw({:unknown_struct_field, mod, field})
end
:error ->
throw({:non_existing_atom, field})
end
end
{:ok, result}
catch
:throw, reason ->
{:error, reason}
end
end
defp to_existing_atom_safe(bin) when is_binary(bin) do
try do
String.to_existing_atom(bin)
rescue
ArgumentError -> :error
else
atom -> {:ok, atom}
end
end
defp format_error(:missing_struct_key),
do: "the given map doesn't contain a \"__struct__\" key"
defp format_error({:bad_module_name, name}) when is_binary(name),
do: "not an elixir module: #{inspect name}"
defp format_error({:non_struct, mod}) when is_atom(mod),
do: "module is not a struct: #{inspect mod}"
defp format_error({:non_existing_atom, bin}) when is_binary(bin),
do: "atom doesn't exist: #{inspect bin}"
defp 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