Current section
Files
Jump to
Current section
Files
lib/splode.ex
defmodule Splode do
@moduledoc """
Use this module to create your error aggregator and handler.
For example:
```elixir
defmodule MyApp.Errors do
use Splode, error_classes: [
invalid: MyApp.Errors.Invalid,
unknown: MyApp.Errors.Unknown
],
unknown_error: MyApp.Errors.Unknown.Unknown
end
```
"""
@doc """
Returns true if the given value is a splode error.
"""
@callback splode_error?(term) :: boolean()
@doc """
Sets the path on the error or errors
"""
@callback set_path(Splode.Error.t() | [Splode.Error.t()], term | list(term)) ::
Splode.Error.t() | [Splode.Error.t()]
@doc """
Combine errors into an error class
"""
@callback to_class(any()) :: Splode.Error.t()
@doc """
Turns any value into a splode error
"""
@callback to_error(any()) :: Splode.Error.t()
@doc """
Converts a combination of a module and json input into an Splode exception.
This allows for errors to be serialized and deserialized
"""
@callback from_json(module, map) :: Splode.Error.t()
defmacro __using__(opts) do
quote bind_quoted: [opts: opts], generated: true, location: :keep do
@behaviour Splode
@error_classes Keyword.put_new(
List.wrap(opts[:error_classes]),
:unknown,
Splode.Error.Unknown
)
@unknown_error opts[:unknown_error] ||
raise(
ArgumentError,
"must supply the `unknown_error` option, pointing at a splode error to use in situations where we cannot convert an error."
)
@merge_with List.wrap(opts[:merge_with])
if Enum.empty?(opts[:error_classes]) do
raise ArgumentError,
"must supply at least one error class to `use Splode`, via `use Splode, error_classes: [class: ModuleForClass]`"
end
@type error_class() ::
unquote(@error_classes |> Keyword.keys() |> Enum.reduce(&{:|, [], [&1, &2]}))
@type class_module() ::
unquote(@error_classes |> Keyword.values() |> Enum.reduce(&{:|, [], [&1, &2]}))
@type t :: %{
required(:__struct__) => module(),
required(:__exception__) => true,
required(:class) => error_class(),
required(:bread_crumbs) => list(String.t()),
required(:vars) => Keyword.t(),
required(:stacktrace) => Splode.Stacktrace.t() | nil,
required(:context) => map(),
optional(atom) => any
}
@type class :: %{
required(:__struct__) => class_module(),
required(:__exception__) => true,
required(:errors) => list(t()),
required(:class) => error_class(),
required(:bread_crumbs) => list(String.t()),
required(:vars) => Keyword.t(),
required(:stacktrace) => Splode.Stacktrace.t() | nil,
required(:context) => map(),
optional(atom) => any
}
@class_modules Keyword.values(@error_classes) |> Enum.reject(&is_nil/1)
@error_class_indices @error_classes |> Keyword.keys() |> Enum.with_index() |> Enum.into(%{})
@doc """
Raises an error if the result is an error, otherwise returns the result
Alternatively, you can use the `defsplode` macro, which does this automatically.
### Options
- `:error_opts` - Options to pass to `to_error/2` when converting the returned error
- `:unknown_error_opts` - Options to pass to the unknown error if the function returns only `:error`.
not necessary if your function always returns `{:error, error}`.
### Examples
def function(arg) do
case do_something(arg) do
:success -> :ok
{:success, result} -> {:ok, result}
{:error, error} -> {:error, error}
end
end
def function!!(arg) do
YourErrors.unwrap!(function(arg))
end
"""
def unwrap!(result, opts \\ nil)
def unwrap!({:ok, result}, _opts), do: result
def unwrap!(:ok, _), do: :ok
def unwrap!({:error, error}, opts), do: raise(to_error(error, opts[:error_opts] || []))
def unwrap!(:error, opts),
do: raise(@error_classes[:unknown].exception(opts[:unknown_error_opts] || []))
def unwrap!(other, opts),
do:
raise(
ArgumentError,
"Invalid value provided to `splode!/2`:\n\n#{inspect(other)}"
)
@impl true
def set_path(errors, path) when is_list(errors) do
Enum.map(errors, &set_path(&1, path))
end
def set_path(error, path) when is_map(error) do
path = List.wrap(path)
error =
if Map.has_key?(error, :path) && is_list(error.path) do
%{error | path: path ++ error.path}
else
error
end
error =
if Map.has_key?(error, :changeset) && error.changeset do
%{
error
| changeset: %{error.changeset | errors: set_path(error.changeset.errors, path)}
}
else
error
end
if Map.has_key?(error, :errors) && is_list(error.errors) do
%{error | errors: Enum.map(error.errors, &set_path(&1, path))}
else
error
end
end
def set_path(error, _), do: error
@impl true
def splode_error?(%struct{}) do
struct.splode_error?()
rescue
_ ->
false
end
def splode_error?(_), do: false
def splode_error?(%struct{splode: splode}, splode) do
struct.splode_error?()
rescue
_ ->
false
end
def splode_error?(%struct{splode: nil}, _splode) do
struct.splode_error?()
rescue
_ ->
false
end
def splode_error?(_, _), do: false
@impl true
def to_class(value, opts \\ [])
def to_class(%struct{errors: [error]} = class, opts)
when struct in @class_modules do
if error.class == :special do
error
else
class
|> accumulate_bread_crumbs(opts[:bread_crumbs])
end
end
def to_class(value, opts) when not is_list(value) do
if splode_error?(value) && value.class == :special do
Map.put(value, :splode, __MODULE__)
else
to_class([value], opts)
end
end
def to_class(values, opts) when is_list(values) do
errors =
if Keyword.keyword?(values) && values != [] do
[to_error(values, Keyword.delete(opts, :bread_crumbs))]
else
values
|> flatten_preserving_keywords()
|> Enum.map(fn error ->
if Enum.any?([__MODULE__ | @merge_with], &splode_error?(error, &1)) do
error
else
to_error(error, Keyword.delete(opts, :bread_crumbs))
end
end)
end
if Enum.count_until(errors, 2) == 1 &&
(Enum.at(errors, 0).class == :special || Enum.at(errors, 0).__struct__.error_class?()) do
List.first(errors)
|> accumulate_bread_crumbs(opts[:bread_crumbs])
else
errors
|> flatten_errors()
|> Enum.uniq_by(&clear_stacktraces/1)
|> Enum.map(fn value ->
if Enum.any?([__MODULE__ | @merge_with], &splode_error?(value, &1)) do
Map.put(value, :splode, value.splode || __MODULE__)
else
exception_opts =
if opts[:stacktrace] do
[
error: value,
stacktrace: %Splode.Stacktrace{stacktrace: opts[:stacktrace]},
splode: __MODULE__
]
else
[error: value, splode: __MODULE__]
end
@unknown_error.exception(exception_opts)
end
end)
|> choose_error()
|> accumulate_bread_crumbs(opts[:bread_crumbs])
|> Map.put(:splode, __MODULE__)
end
end
defp choose_error([]) do
@error_classes[:unknown].exception(splode: __MODULE__)
end
defp choose_error(errors) do
[error | other_errors] =
Enum.sort_by(errors, fn error ->
# the second element here sorts errors that are already parent errors
{Map.get(@error_class_indices, error.class) ||
Map.get(@error_class_indices, :unknown),
@error_classes[error.class] != error.__struct__}
end)
parent_error_module =
@error_classes[error.class] || Keyword.get(@error_classes, :unknown) ||
Splode.Error.Unknown
if parent_error_module == error.__struct__ do
%{error | errors: (error.errors || []) ++ other_errors}
else
parent_error_module.exception(errors: errors, splode: __MODULE__)
end
end
@impl true
def to_error(value, opts \\ [])
def to_error(list, opts) when is_list(list) do
if Keyword.keyword?(list) do
list
|> Keyword.take([:error, :vars])
|> Keyword.put_new(:error, list[:message])
|> Keyword.put_new(:value, list)
|> Keyword.put(:splode, __MODULE__)
|> @unknown_error.exception()
|> add_stacktrace(opts[:stacktrace])
|> accumulate_bread_crumbs(opts[:bread_crumbs])
else
case list do
[item] ->
to_error(item, opts)
list ->
to_class(list, opts)
end
end
end
def to_error(error, opts) when is_binary(error) do
[error: error, splode: __MODULE__]
|> @unknown_error.exception()
|> Map.put(:stacktrace, nil)
|> add_stacktrace(opts[:stacktrace])
|> accumulate_bread_crumbs(opts[:bread_crumbs])
end
def to_error(other, opts) do
cond do
Enum.any?([__MODULE__ | @merge_with], &splode_error?(other, &1)) ->
other
|> Map.put(:splode, other.splode || __MODULE__)
|> add_stacktrace(opts[:stacktrace])
|> accumulate_bread_crumbs(opts[:bread_crumbs])
is_exception(other) ->
[error: Exception.format(:error, other), splode: __MODULE__]
|> @unknown_error.exception()
|> add_stacktrace(opts[:stacktrace])
|> accumulate_bread_crumbs(opts[:bread_crumbs])
true ->
[error: "unknown error: #{inspect(other)}", splode: __MODULE__]
|> @unknown_error.exception()
|> Map.put(:stacktrace, nil)
|> add_stacktrace(opts[:stacktrace])
|> accumulate_bread_crumbs(opts[:bread_crumbs])
end
end
defp flatten_errors(errors) do
errors
|> Enum.flat_map(&List.wrap/1)
|> Enum.flat_map(fn error ->
if Enum.any?([__MODULE__ | @merge_with], &splode_error?(error, &1)) do
if error.__struct__.error_class?() do
flatten_errors(error.errors)
else
[error]
end
else
[error]
end
end)
end
defp flatten_preserving_keywords(list) do
if Keyword.keyword?(list) do
[list]
else
Enum.flat_map(list, fn item ->
cond do
Keyword.keyword?(item) ->
[item]
is_list(item) ->
flatten_preserving_keywords(item)
true ->
[item]
end
end)
end
end
defp add_stacktrace(%{stacktrace: _} = error, stacktrace) do
stacktrace =
case stacktrace do
%Splode.Stacktrace{stacktrace: nil} ->
nil
nil ->
nil
stacktrace ->
%Splode.Stacktrace{stacktrace: stacktrace}
end
%{error | stacktrace: stacktrace || error.stacktrace || fake_stacktrace()}
end
defp add_stacktrace(e, _), do: e
defp fake_stacktrace do
{:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace)
%Splode.Stacktrace{stacktrace: Enum.drop(stacktrace, 3)}
end
defp accumulate_bread_crumbs(error, bread_crumbs) when is_list(bread_crumbs) do
bread_crumbs
|> Enum.reverse()
|> Enum.reduce(error, &accumulate_bread_crumbs(&2, &1))
end
defp accumulate_bread_crumbs(%module{errors: errors} = error, bread_crumbs)
when is_binary(bread_crumbs) and module in @class_modules do
updated_errors = accumulate_bread_crumbs(errors, bread_crumbs)
add_bread_crumbs(%{error | errors: updated_errors}, bread_crumbs)
end
defp accumulate_bread_crumbs(errors, bread_crumbs)
when is_list(errors) and is_binary(bread_crumbs) do
Enum.map(errors, &add_bread_crumbs(&1, bread_crumbs))
end
defp accumulate_bread_crumbs(error, bread_crumbs) do
add_bread_crumbs(error, bread_crumbs)
end
defp add_bread_crumbs(error, bread_crumbs) when is_list(bread_crumbs) do
bread_crumbs
|> Enum.reverse()
|> Enum.reduce(error, &add_bread_crumbs(&2, &1))
end
defp add_bread_crumbs(error, bread_crumb) when is_binary(bread_crumb) do
%{error | bread_crumbs: [bread_crumb | error.bread_crumbs]}
end
defp add_bread_crumbs(error, _) do
error
end
@impl true
def from_json(module, json) do
{handled, unhandled} = process_known_json_keys(json)
unhandled =
Map.update(unhandled, "vars", [], fn vars ->
Map.to_list(vars)
end)
json = Map.merge(unhandled, handled)
module.from_json(json)
end
defp process_known_json_keys(json) do
{handled, unhandled} = Map.split(json, ~w(field fields message path))
handled =
handled
|> update_if_present("field", &String.to_existing_atom/1)
|> update_if_present("fields", fn fields ->
fields
|> List.wrap()
|> Enum.map(&Splode.Error.atomize_safely/1)
end)
|> update_if_present("path", fn item ->
item
|> List.wrap()
|> Enum.map(fn
item when is_integer(item) ->
item
item when is_binary(item) ->
case Integer.parse(item) do
{integer, ""} -> integer
_ -> item
end
end)
end)
{handled, unhandled}
end
defp clear_stacktraces(%{stacktrace: stacktrace} = error) when not is_nil(stacktrace) do
clear_stacktraces(%{error | stacktrace: nil})
end
defp clear_stacktraces(%{errors: errors} = exception) when is_list(errors) do
%{exception | errors: Enum.map(errors, &clear_stacktraces/1)}
end
defp clear_stacktraces(error), do: error
defp update_if_present(handled, key, fun) do
if Map.has_key?(handled, key) do
Map.update!(handled, key, fun)
else
handled
end
end
defoverridable set_path: 2
end
end
end