Packages

An Elixir decoder-generator library that leans on typespecs

Current section

Files

Jump to
breakfast lib breakfast.ex
Raw

lib/breakfast.ex

defmodule Breakfast do
@moduledoc Breakfast.Util.hexdocs_from_markdown("README.md")
alias Breakfast.{Field, Yogurt}
@typep result(t) :: Breakfast.Util.result(t)
defmacro __using__(opts) do
quote do
use Breakfast.Using, unquote(opts)
end
end
@doc """
The function to call when you want to decode some data.
It requires that you've defined your decoder module and takes the module name as a parameter.
See the [quick start](#module-quick-start) guide for more on defining and user decoder modules.
## Examples
iex> defmodule Customer do
...> use Breakfast
...>
...> cereal do
...> field :id, non_neg_integer()
...> field :email, String.t()
...> end
...> end
...>
...> data = %{
...> "id" => 5,
...> "email" => "leo@aol.com"
...> }
...>
...> Breakfast.decode(Customer, data)
%Breakfast.Yogurt{
errors: [],
params: %{"email" => "leo@aol.com", "id" => 5},
struct: %Customer{email: "leo@aol.com", id: 5}
}
"""
@spec decode(mod :: module(), params :: term()) :: Yogurt.t()
def decode(mod, params) do
fields = mod.__cereal__(:fields)
yogurt =
Enum.reduce(
fields,
%Yogurt{struct: struct(mod), params: params, fields: fields},
fn %Field{
name: name,
fetcher: fetcher,
caster: caster,
type: type,
validator: validator
} = field,
%Yogurt{errors: errors, struct: struct} = yogurt ->
with {:fetch, {:ok, value}} <- {:fetch, fetch(params, field)},
{:cast, {:ok, cast_value}} <- {:cast, cast(value, field)},
{:validate, []} <- {:validate, validate(cast_value, field)} do
%Yogurt{yogurt | struct: %{struct | name => cast_value}}
else
{:fetch, :error} ->
%Yogurt{yogurt | errors: [{name, "value not found"} | errors]}
{:cast, :error} ->
%Yogurt{yogurt | errors: [{name, "cast error"} | errors]}
{:validate, validation_errors} when is_list(validation_errors) ->
%Yogurt{yogurt | errors: Enum.map(validation_errors, &{name, &1}) ++ errors}
{:fetch, retval} ->
raise Breakfast.FetchError,
message:
"Expected fetcher for `#{name}` (`#{inspect(fetcher)}`) to return `{:ok, value}` or `:error`, got: `#{
inspect(retval)
}`",
field: name,
type: type,
fetcher: fetcher
{:cast, retval} ->
raise Breakfast.CastError,
message:
"Expected caster for `#{name}` (`#{inspect(caster)}`) to return `{:ok, value}` or `:error`, got: `#{
inspect(retval)
}`",
field: name,
type: type,
caster: caster
{:validate, retval} ->
raise Breakfast.ValidateError,
message:
"Expected validator for `#{name}` (`#{inspect(validator)}`) to return a list, got: `#{
inspect(retval)
}`",
field: name,
type: type,
validator: validator
end
end
)
%Yogurt{yogurt | errors: Enum.reverse(yogurt.errors)}
end
@spec fetch(term(), Field.t()) :: result(term())
defp fetch(params, field) do
with :error <- do_fetch(params, field), do: field.default
end
@spec do_fetch(term(), Field.t()) :: result(term())
defp do_fetch(params, %Field{name: name, fetcher: :default}) do
if is_map(params) do
Map.fetch(params, to_string(name))
else
:error
end
end
defp do_fetch(params, %Field{mod: mod, name: name, fetcher: fetcher}),
do: apply_fn(mod, fetcher, [params, name])
##
# We want to make sure that any nested Breakfast decoders that are a part of the field's type get
# properly casted using that decoder.
#
# In order to do this, we:
# - Traverse the field's type and find all the places where a value might need to be casted
# using a decoder
# - Iterate through these locations in the type and attempt to cast that part of the value as needed
#
# This should properly cast values regardless of how deep in the data structre they are, and do its best
# to handle cases like: a value 5 levels deep has a union type where one of the union types is a Breakfast decoder.
@spec cast_any_nested_decoder_values(term(), Field.t()) :: term()
defp cast_any_nested_decoder_values(value, field) do
field
|> find_all_nested_decoder_paths_in_type()
|> Enum.reduce(value, &do_cast_any_nested_decoder_values/2)
end
@spec do_cast_any_nested_decoder_values({list(), term()}, term(), boolean()) :: term()
defp do_cast_any_nested_decoder_values(path_and_type, value, strict? \\ true)
defp do_cast_any_nested_decoder_values({[], {:cereal, module}}, value, strict?) do
with value when not is_nil(value) <- value,
%Yogurt{errors: [], struct: struct} <- Breakfast.decode(module, value) do
struct
else
nil ->
if strict?,
do: %Yogurt{
errors: ["value that was expected to cast to a #{inspect(module)}.t() was nil"]
},
else: value
%Yogurt{errors: [_ | _]} = yogurt ->
if strict?, do: yogurt, else: value
end
end
defp do_cast_any_nested_decoder_values({[:list | rest], type}, value, strict?) do
Enum.map(value, &do_cast_any_nested_decoder_values({rest, type}, &1, strict?))
end
defp do_cast_any_nested_decoder_values({[{:tuple, index} | rest], type}, value, strict?) do
value
|> Tuple.to_list()
|> Enum.with_index()
|> Enum.map(fn
{value, ^index} ->
do_cast_any_nested_decoder_values({rest, type}, value, strict?)
{value, _} ->
value
end)
|> List.to_tuple()
end
defp do_cast_any_nested_decoder_values(
{[{:map, {require_type, key_type}} | rest], type},
value,
strict?
)
when is_map(value) do
value
|> Enum.map(fn {key, value} ->
if Breakfast.Type.validate(key_type, key) == [] do
{key,
do_cast_any_nested_decoder_values(
{rest, type},
value,
strict? and require_type == :required
)}
else
{key, value}
end
end)
|> Enum.into(%{})
end
defp do_cast_any_nested_decoder_values({[:union | rest], type}, value, _strict?) do
do_cast_any_nested_decoder_values({rest, type}, value, false)
end
defp do_cast_any_nested_decoder_values(_path, value, _strict?), do: value
@spec find_all_nested_decoder_paths_in_type(Field.t()) :: [{list(), term()}]
defp find_all_nested_decoder_paths_in_type(field) do
field
|> do_find_all_nested_decoder_paths_in_type(field.type, [], [])
|> Enum.map(fn {path, type} -> {Enum.reverse(path), type} end)
end
@spec do_find_all_nested_decoder_paths_in_type(Field.t(), term(), list(), list()) :: [
{term(), term()}
]
defp do_find_all_nested_decoder_paths_in_type(field, type, current_path, paths)
defp do_find_all_nested_decoder_paths_in_type(
_field,
{:cereal, _} = cereal,
current_path,
paths
) do
[{current_path, cereal} | paths]
end
defp do_find_all_nested_decoder_paths_in_type(field, {:list, type}, current_path, paths) do
field
|> do_find_all_nested_decoder_paths_in_type(type, [:list | current_path], paths)
|> Enum.concat(paths)
end
defp do_find_all_nested_decoder_paths_in_type(field, {:tuple, types}, current_path, paths) do
types
|> Enum.with_index()
|> Enum.flat_map(fn {type, index} ->
do_find_all_nested_decoder_paths_in_type(
field,
type,
[{:tuple, index} | current_path],
paths
)
end)
|> Enum.concat(paths)
end
defp do_find_all_nested_decoder_paths_in_type(
field,
{:map, {required, optional}},
current_path,
paths
) do
required_paths =
Enum.flat_map(required, fn {key_type, value_type} ->
do_find_all_nested_decoder_paths_in_type(
field,
value_type,
[{:map, {:required, key_type}} | current_path],
paths
)
end)
optional_paths =
Enum.flat_map(optional, fn {key_type, value_type} ->
do_find_all_nested_decoder_paths_in_type(
field,
value_type,
[{:map, {:optional, key_type}} | current_path],
paths
)
end)
required_paths ++ optional_paths ++ paths
end
defp do_find_all_nested_decoder_paths_in_type(field, {:union, types}, current_path, paths) do
types
|> Enum.flat_map(fn type ->
do_find_all_nested_decoder_paths_in_type(
field,
type,
[:union | current_path],
paths
)
end)
|> Enum.concat(paths)
end
defp do_find_all_nested_decoder_paths_in_type(_field, _type, _current_path, _paths), do: []
@spec cast(term(), Field.t()) :: result(term())
defp cast(value, field) do
value
|> cast_any_nested_decoder_values(field)
|> do_cast(field)
end
@spec do_cast(term(), Field.t()) :: result(term())
defp do_cast(value, %Field{default: {:ok, value}, caster: :default}), do: {:ok, value}
defp do_cast(value, %Field{caster: :default}), do: {:ok, value}
defp do_cast(value, %Field{mod: mod, caster: caster}), do: apply_fn(mod, caster, [value])
@spec validate(term(), Field.t()) :: [String.t()]
defp validate(value, %Field{validator: :default, type: type}),
do: Breakfast.Type.validate(type, value)
defp validate(value, %Field{mod: mod, validator: validator}),
do: apply_fn(mod, validator, [value])
@spec apply_fn(module(), fun() | {atom(), keyword()} | atom(), [term()]) :: term()
defp apply_fn(_mod, fun, [arg1]) when is_function(fun, 1), do: fun.(arg1)
defp apply_fn(_mod, fun, [arg1, arg2]) when is_function(fun, 2), do: fun.(arg1, arg2)
defp apply_fn(_mod, fun, [arg1, arg2, arg3]) when is_function(fun, 3),
do: fun.(arg1, arg2, arg3)
defp apply_fn(mod, {fun, opts}, args) when is_atom(fun) and is_list(args) and is_list(opts),
do: apply(mod, fun, args ++ [opts])
defp apply_fn(mod, fun, args) when is_atom(fun) and is_list(args), do: apply(mod, fun, args)
defp apply_fn(_mod, {mod, {fun, opts}}, args)
when is_atom(mod) and is_atom(fun) and is_list(args) and is_list(opts),
do: apply(mod, fun, args ++ [opts])
defp apply_fn(_mod, {mod, fun}, args) when is_atom(mod) and is_atom(fun) and is_list(args),
do: apply(mod, fun, args)
end