diff --git a/documentation/tutorials/get-started-with-splode.md b/documentation/tutorials/get-started-with-splode.md index 5c87f0c..d650996 100644 --- a/documentation/tutorials/get-started-with-splode.md +++ b/documentation/tutorials/get-started-with-splode.md @@ -17,29 +17,21 @@ end # Error classes are splode errors with an `errors` key. defmodule MyApp.Errors.Invalid do - use Splode.Error, fields: [:errors], class: :invalid - - def splode_message(%{errors: errors}) do - Splode.ErrorClass.error_messages(errors) - end + use Splode.ErrorClass, class: :invalid end # You will want to define an unknown error class, # otherwise splode will use its own defmodule MyApp.Errors.Unknown do - use Splode.Error, fields: [:errors], class: :unknown - - def splode_message(%{errors: errors}) do - Splode.ErrorClass.error_messages(errors) - end + use Splode.ErrorClass, class: :unknown end # This fallback exception will be used for unknown errors defmodule MyApp.Errors.Unknown.Unknown do - use Splode.Error, fields: [:error], class: :unknown + use Splode.Error, class: :unknown # your unknown message should have an `error` key - def splode_message(%{error: error}) do + def message(%{error: error}) do if is_binary(error) do to_string(error) else @@ -53,7 +45,7 @@ end defmodule MyApp.Errors.InvalidArgument do use Splode.Error, fields: [:name, :message], class: :invalid - def splode_message(%{name: name, message: message}) do + def message(%{name: name, message: message}) do "Invalid argument #{name}: #{message}" end end diff --git a/lib/splode.ex b/lib/splode.ex index d3f8104..f84f6db 100644 --- a/lib/splode.ex +++ b/lib/splode.ex @@ -139,10 +139,27 @@ defmodule Splode do 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 + def to_class(%struct{errors: [error]} = class, _opts) + when struct in @class_modules do if error.class == :special do error else @@ -152,7 +169,7 @@ defmodule Splode do def to_class(value, opts) when not is_list(value) do if splode_error?(value) && value.class == :special do - value + Map.put(value, :splode, __MODULE__) else to_class([value], opts) end @@ -174,14 +191,18 @@ defmodule Splode do |> flatten_preserving_keywords() |> Enum.uniq_by(&clear_stacktraces/1) |> Enum.map(fn value -> - if splode_error?(value) do - value + if splode_error?(value, __MODULE__) do + Map.put(value, :splode, __MODULE__) else exception_opts = if opts[:stacktrace] do - [error: value, stacktrace: %Splode.Stacktrace{stacktrace: opts[:stacktrace]}] + [ + error: value, + stacktrace: %Splode.Stacktrace{stacktrace: opts[:stacktrace]}, + splode: __MODULE__ + ] else - [error: value] + [error: value, splode: __MODULE__] end @unknown_error.exception(exception_opts) @@ -189,11 +210,12 @@ defmodule Splode do end) |> choose_error() |> accumulate_bread_crumbs(opts[:bread_crumbs]) + |> Map.put(:splode, __MODULE__) end end defp choose_error([]) do - @error_classes[:unknown].exception([]) + @error_classes[:unknown].exception(splode: __MODULE__) end defp choose_error(errors) do @@ -211,7 +233,7 @@ defmodule Splode do if parent_error_module == error.__struct__ do %{error | errors: (error.errors || []) ++ other_errors} else - parent_error_module.exception(errors: errors) + parent_error_module.exception(errors: errors, splode: __MODULE__) end end @@ -224,6 +246,7 @@ defmodule Splode do |> 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]) @@ -239,7 +262,7 @@ defmodule Splode do end def to_error(error, opts) when is_binary(error) do - [error: error] + [error: error, splode: __MODULE__] |> @unknown_error.exception() |> Map.put(:stacktrace, nil) |> add_stacktrace(opts[:stacktrace]) @@ -248,20 +271,21 @@ defmodule Splode do def to_error(other, opts) do cond do - splode_error?(other) -> + splode_error?(other, __MODULE__) -> other + |> Map.put(:splode, __MODULE__) |> add_stacktrace(opts[:stacktrace]) |> accumulate_bread_crumbs(opts[:bread_crumbs]) is_exception(other) -> - [error: Exception.format(:error, other)] + [error: Exception.format(:error, other), splode: __MODULE__] |> @unknown_error.exception() |> Map.put(:stacktrace, nil) |> add_stacktrace(opts[:stacktrace]) |> accumulate_bread_crumbs(opts[:bread_crumbs]) true -> - [error: "unknown error: #{inspect(other)}"] + [error: "unknown error: #{inspect(other)}", splode: __MODULE__] |> @unknown_error.exception() |> Map.put(:stacktrace, nil) |> add_stacktrace(opts[:stacktrace]) diff --git a/lib/splode/error.ex b/lib/splode/error.ex index 614cd73..2160496 100644 --- a/lib/splode/error.ex +++ b/lib/splode/error.ex @@ -8,7 +8,7 @@ defmodule Splode.Error do defmodule MyApp.Errors.InvalidArgument do use Splode.Error, fields: [:name, :message], class: :invalid - def splode_message(%{name: name, message: message}) do + def message(%{name: name, message: message}) do "Invalid argument \#{name}: \#{message}" end end @@ -16,7 +16,7 @@ defmodule Splode.Error do """ @callback splode_error?() :: boolean() @callback from_json(map) :: struct() - @callback splode_message(struct()) :: String.t() + @callback error_class?() :: boolean() @type t :: Exception.t() @doc false @@ -28,8 +28,9 @@ defmodule Splode.Error do end defmacro __using__(opts) do - quote generated: true, bind_quoted: [opts: opts] do + quote generated: true, bind_quoted: [opts: opts, mod: __MODULE__] do @behaviour Splode.Error + @error_class !!opts[:error_class?] if !opts[:class] do raise "Must provide an error class for a splode error, i.e `use Splode.Error, class: :invalid`" @@ -37,6 +38,7 @@ defmodule Splode.Error do defexception List.wrap(opts[:fields]) ++ [ + splode: nil, bread_crumbs: [], vars: [], path: [], @@ -44,43 +46,47 @@ defmodule Splode.Error do class: opts[:class] ] + @before_compile mod + @impl Splode.Error def splode_error?, do: true - @impl Exception - def message(%{vars: vars} = exception) do - string = splode_message(exception) + @impl Splode.Error + def error_class?, do: @error_class - string = - case Splode.ErrorClass.bread_crumb(exception.bread_crumbs) do - "" -> - string - - context -> - context <> "\n" <> string - end - - Enum.reduce(List.wrap(vars), string, fn {key, value}, acc -> - if String.contains?(acc, "%{#{key}}") do - String.replace(acc, "%{#{key}}", to_string(value)) - else - acc - end - end) - end + def exception, do: exception([]) @impl Exception def exception(opts) do - opts = - if is_nil(opts[:stacktrace]) do - {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) + if @error_class && match?([%error{class: :special} = special], opts[:errors]) do + special_error = Enum.at(opts[:errors], 0) - Keyword.put(opts, :stacktrace, %Splode.Stacktrace{stacktrace: stacktrace}) + if special_error.__struct__.splode_error?() do + special_error else - opts - end + opts = + if is_nil(opts[:stacktrace]) do + {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) - super(opts) |> Map.update(:vars, [], &Splode.Error.clean_vars/1) + Keyword.put(opts, :stacktrace, %Splode.Stacktrace{stacktrace: stacktrace}) + else + opts + end + + super(opts) |> Map.update(:vars, [], &Splode.Error.clean_vars/1) + end + else + opts = + if is_nil(opts[:stacktrace]) do + {:current_stacktrace, stacktrace} = Process.info(self(), :current_stacktrace) + + Keyword.put(opts, :stacktrace, %Splode.Stacktrace{stacktrace: stacktrace}) + else + opts + end + + super(opts) |> Map.update(:vars, [], &Splode.Error.clean_vars/1) + end end @impl Splode.Error @@ -97,6 +103,36 @@ defmodule Splode.Error do end end + defmacro __before_compile__(env) do + if Module.defines?(env.module, {:message, 1}, :def) do + quote generated: true do + defoverridable message: 1 + + @impl true + def message(%{vars: vars} = exception) do + string = super(exception) + + string = + case Splode.ErrorClass.bread_crumb(exception.bread_crumbs) do + "" -> + string + + context -> + context <> "\n" <> string + end + + Enum.reduce(List.wrap(vars), string, fn {key, value}, acc -> + if String.contains?(acc, "%{#{key}}") do + String.replace(acc, "%{#{key}}", to_string(value)) + else + acc + end + end) + end + end + end + end + @doc false def clean_vars(vars) when is_map(vars) do clean_vars(Map.to_list(vars)) diff --git a/lib/splode/error_class.ex b/lib/splode/error_class.ex index d182d36..6074cba 100644 --- a/lib/splode/error_class.ex +++ b/lib/splode/error_class.ex @@ -1,6 +1,38 @@ defmodule Splode.ErrorClass do @moduledoc "Tools for working with error classes" + defmacro __using__(opts) do + quote bind_quoted: [opts: opts] do + opts = + Keyword.update(opts, :fields, [errors: []], fn fields -> + has_error_fields? = + Enum.any?(fields, fn + :errors -> + true + + {:errors, _} -> + true + + _ -> + false + end) + + if has_error_fields? do + fields + else + fields ++ [errors: []] + end + end) + |> Keyword.put(:error_class?, true) + + use Splode.Error, opts + + def message(%{errors: errors}) do + Splode.ErrorClass.error_messages(errors) + end + end + end + @doc "Creates a long form composite error message for a list of errors" def error_messages(errors, opts \\ []) do custom_message = opts[:custom_message] diff --git a/lib/splode/unknown.ex b/lib/splode/unknown.ex index 93a3c82..88bf11b 100644 --- a/lib/splode/unknown.ex +++ b/lib/splode/unknown.ex @@ -1,11 +1,8 @@ defmodule Splode.Error.Unknown do @moduledoc "The default top level unknown error container" - use Splode.Error, fields: [:errors], class: :unknown - - def splode_message(exception) do - Splode.ErrorClass.error_messages(exception.errors) - end + use Splode.ErrorClass, class: :unknown + @impl true def exception(opts) do if opts[:error] do super(Keyword.update(opts, :errors, [opts[:error]], &[opts[:error] | &1]))