# SPDX-FileCopyrightText: 2024 splode contributors # # SPDX-License-Identifier: MIT 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