diff --git a/documentation/topics/development/error-handling.md b/documentation/topics/development/error-handling.md index 9d2686e00..e1000ac2f 100644 --- a/documentation/topics/development/error-handling.md +++ b/documentation/topics/development/error-handling.md @@ -60,15 +60,29 @@ end ## Generating Errors -When returning errors in your application, you can a few different things: +When returning errors from behaviors or adding errors to a +changeset/query/action input, multiple formats are supported. You can return a +simple String, which will be converted into an `Ash.Error.Unknown` exception. +You can also return a keyword list containing `field` or `fields` and `message`, +which will be used to construct an `Ash.Error.Invalid.InvalidChanges` error. +Finally, you can pass an exception directly, which will be used as is if it is +an Ash error, or wrapped in an `Ash.Error.Unknown` if it is not. -## Return a keyword list in changes and validations +Technically *any* value can be used as an error, but will be wrapped in an +`Ash.Error.Unknown` accordingly. -A shortcut for creating errors is to return a keyword list containing `field` -and `message`. This works in changes and validations. For example: +> ### Use exception modules {: .info} +> +> You should prefer to use the exception modules provided by Ash, or ones +> that you have defined manually. This allows you to document your error +> types, and to show those errors over API interfaces. See the section +> on APIs below for more. + +## Examples of using non standard errors + +### Keyword list (`Ash.Error.Changes.InvalidChanges`) ```elixir -# in a change, you use `Ash.Changeset.add_error/2` def change(changeset, _, _) do if under_21?(changeset) do Ash.Changeset.add_error(changeset, field: :age, message: "must be 21 or older") @@ -76,18 +90,21 @@ def change(changeset, _, _) do changeset end end +``` -# in a validation, you return the error in an `{:error, error}` tuple. +### String (`Ash.Error.Unknown.UnknownError`) + +```elixir def change(changeset, _, _) do if under_21?(changeset) do - {:error, field: :age, message: "must be 21 or older"} + Ash.Changeset.add_error(changeset, "must be 21 or older") else - :ok + changeset end end ``` -## Using a Builtin Exception +## Using an exception module These are all modules under `Ash.Error.*`. You can create a new one with `error.exception(options)`, and the options are documented in each exception. This documentation is missing in some cases. Go to the source code of the exception to see its special options. All of them support the `vars` option, which are values to be interpolated into the message, useful for things like translation. @@ -95,7 +112,7 @@ For example: ```elixir def change(changeset, _, _) do - if some_condition(changeset) do + if under_21?(changeset) do error = Ash.Error.Changes.Required.exception( field: :foo, type: :attribute, @@ -109,26 +126,36 @@ def change(changeset, _, _) do end ``` -## Using a Custom Exception +### Using a Custom Exception You can create a custom exception like so. This is an example of a builtin exception that you could mirror to build your own ```elixir -defmodule Ash.Error.Action.InvalidArgument do - @moduledoc "Used when an invalid value is provided for an action argument" - use Splode.Error, fields: [:field, :message, :value], class: :invalid +defmodule MyApp.Errors.Invalid.TooYoung do + @moduledoc "Used when a user who is too young is attempted to be created" + use Splode.Error, fields: [:age], class: :invalid def message(error) do """ - Invalid value provided#{for_field(error)}#{do_message(error)} - - #{inspect(error.value)} + Must be 21 or older, got: #{error.age}. """ end end + +def change(changeset, _, _) do + if under_21?(changeset) do + error = MyApp.Errors.Invalid.TooYoung.exception( + age: Ash.Changeset.get_attribute(changeset, :age) + ) + + Ash.Changeset.add_error(changeset, error) + else + changeset + end +end ``` -## API Extensions +## Showing errors over APIs AshJsonApi and AshGraphql both use a special protocol to determine how (and if) a raised or returned error should be displayed. See [AshJsonApi.Error](https://hexdocs.pm/ash_json_api/AshJsonApi.Error.html) and [AshGraphl.Error](https://hexdocs.pm/ash_graphql/AshGraphql.Error.html) diff --git a/lib/ash/action_input.ex b/lib/ash/action_input.ex index b8ecf35d9..7be9c31bc 100644 --- a/lib/ash/action_input.ex +++ b/lib/ash/action_input.ex @@ -364,8 +364,13 @@ defmodule Ash.ActionInput do end) end - @doc "Adds an error to the input errors list, and marks the input as `valid?: false`" - @spec add_error(t(), term | String.t() | list(term | String.t())) :: t() + @doc """ + Add an error to the errors list and mark the action input as invalid. + + See `Ash.Error.to_ash_error/3` for more on supported values for `error` + """ + @spec add_error(t(), Ash.Error.error_input() | list(Ash.Error.error_input()), Ash.Error.path_input()) :: t() + @spec add_error(t(), Ash.Error.error_input() | list(Ash.Error.error_input())) :: t() def add_error(input, errors, path \\ []) def add_error(input, errors, path) when is_list(errors) do diff --git a/lib/ash/actions/create/bulk.ex b/lib/ash/actions/create/bulk.ex index 931ac59e1..f3d94eb30 100644 --- a/lib/ash/actions/create/bulk.ex +++ b/lib/ash/actions/create/bulk.ex @@ -223,7 +223,7 @@ defmodule Ash.Actions.Create.Bulk do errors = Enum.map(errors, fn error -> - Ash.Error.to_ash_error(error, + Ash.Error.to_ash_error(error, [], bread_crumbs: [ "Exception raised in bulk create: #{inspect(resource)}.#{action.name}" ] diff --git a/lib/ash/actions/destroy/bulk.ex b/lib/ash/actions/destroy/bulk.ex index d5baa3117..6f85305ec 100644 --- a/lib/ash/actions/destroy/bulk.ex +++ b/lib/ash/actions/destroy/bulk.ex @@ -1118,7 +1118,7 @@ defmodule Ash.Actions.Destroy.Bulk do errors = Enum.map( errors, - &Ash.Error.to_ash_error(&1, + &Ash.Error.to_ash_error(&1, [], bread_crumbs: [ "Returned from bulk destroy: #{inspect(resource)}.#{action_name}" ] diff --git a/lib/ash/actions/update/bulk.ex b/lib/ash/actions/update/bulk.ex index e9b474196..e00c8ea67 100644 --- a/lib/ash/actions/update/bulk.ex +++ b/lib/ash/actions/update/bulk.ex @@ -1353,7 +1353,7 @@ defmodule Ash.Actions.Update.Bulk do errors = Enum.map( errors, - &Ash.Error.to_ash_error(&1, + &Ash.Error.to_ash_error(&1, [], bread_crumbs: [ "Returned from bulk update: #{inspect(resource)}.#{action_name}" ] diff --git a/lib/ash/changeset/changeset.ex b/lib/ash/changeset/changeset.ex index 2e81a19f6..39e7a63e6 100644 --- a/lib/ash/changeset/changeset.ex +++ b/lib/ash/changeset/changeset.ex @@ -289,15 +289,8 @@ defmodule Ash.Changeset do valid?: boolean } - @type error_info :: - String.t() - | [ - {:field, atom()} - | {:fields, [atom()]} - | {:message, String.t()} - | {:value, any()} - ] - | Ash.Error.t() + @doc deprecated: "Use `Ash.Error.error_input()` instead" + @type error_info :: Ash.Error.error_input() alias Ash.Error.{ Changes.InvalidArgument, @@ -5839,21 +5832,13 @@ defmodule Ash.Changeset do defp record_added_filter(changeset, _), do: changeset @doc """ - Adds an error to the changesets errors list, and marks the change as `valid?: false`. - - ## Error Data - - The given `errors` argument can be a string, a keyword list, a struct, or a list of any of the three. + Add an error to the errors list and mark the changeset as invalid. - If `errors` is a keyword list, or a list of keyword lists, the following keys are supported in the keyword list: - - - `field` (atom) - the field that the error is for. This is required, unless `fields` is given. - - `fields` (list of atoms) - the fields that the error is for. This is required, unless `field` is given. - - `message` (string) - the error message - - `value` (any) - (optional) the field value that caused the error + See `Ash.Error.to_ash_error/3` for more on supported values for `error` """ - @spec add_error(t(), error_info() | [error_info()], Keyword.t()) :: t() - @spec add_error(t(), term | String.t() | list(term | String.t())) :: t() + @spec add_error(t(), Ash.Error.error_input(), path :: Ash.Error.path_input()) :: t() + @spec add_error(t(), Ash.Error.error_input()) :: t() + def add_error(changeset, errors, path \\ []) def add_error(changeset, errors, path) when is_list(errors) do diff --git a/lib/ash/error/error.ex b/lib/ash/error/error.ex index cb8007de3..666445e0f 100644 --- a/lib/ash/error/error.ex +++ b/lib/ash/error/error.ex @@ -11,18 +11,39 @@ defmodule Ash.Error do ], unknown_error: Ash.Error.Unknown.UnknownError - @type ash_errors :: Exception.t() + @type error_keyword_option :: + {:field, atom()} + | {:fields, list(atom)} + | {:value, term()} + | {:message, String.t()} + | {:path, list(atom | String.t())} + @type error_keyword :: list(error_keyword_option) + + @type ash_error :: Exception.t() @type ash_error_subject :: Ash.Changeset.t() | Ash.Query.t() | Ash.ActionInput.t() + @type path :: [String.t() | atom() | integer()] + @type path_input :: [String.t() | atom() | integer()] | String.t() | atom() | integer() + + @type error_input :: + ash_error() | error_keyword() | String.t() | ash_error_subject() | Exception.t() | any() + @doc """ - Converts a value with optional stacktrace and opts to - an error within Ash. + Converts a value to an Ash exception. + + The supported inputs to this function can be provided to various places, + like `Ash.Query.add_error/2`, `Ash.Changeset.add_error/2` and `Ash.ActionInput.add_error/2`. + + Additionally, any place that you can *return* an error you can return instead a valid + error input. + + See [the error handling guide](/documentation/development/error-handling.md) for more. """ @spec to_ash_error( - ash_error_subject() | term() | [ash_error_subject() | term()], - list() | nil, + error_input() | list(error_input), + Exception.stacktrace() | nil, Keyword.t() - ) :: ash_errors() | [ash_errors()] + ) :: ash_error() | [ash_error()] def to_ash_error(value, stacktrace \\ nil, opts \\ []) do value = value @@ -140,13 +161,18 @@ defmodule Ash.Error do @doc """ This function prepends the provided path to any existing path on the errors. """ - @spec set_path(ash_error_subject() | term(), term() | [term()]) :: - ash_error_subject() | ash_errors() + @spec set_path(ash_error() | list(ash_error()), path_input()) :: ash_error() | list(ash_error()) + + @spec set_path(ash_error_subject(), path_input()) :: ash_error_subject() def set_path(%struct{errors: errors} = container, path) when struct in [Ash.Changeset, Ash.ActionInput, Ash.Query] do %{container | errors: set_path(errors, path)} end + def set_path(errors, path) when is_list(errors) do + Enum.map(errors, &set_path(&1, path)) + end + def set_path(error, path) do super(error, path) end diff --git a/lib/ash/helpers.ex b/lib/ash/helpers.ex index f34f7a50f..79c229656 100644 --- a/lib/ash/helpers.ex +++ b/lib/ash/helpers.ex @@ -308,7 +308,7 @@ defmodule Ash.Helpers do {:ok, result, query} {:error, error} -> - {:error, Ash.Error.to_ash_error(error, query: query)} + {:error, Ash.Error.to_ash_error(error, nil, query: query)} end end diff --git a/lib/ash/query/query.ex b/lib/ash/query/query.ex index 1c101b926..92ecdef2d 100644 --- a/lib/ash/query/query.ex +++ b/lib/ash/query/query.ex @@ -3042,36 +3042,30 @@ defmodule Ash.Query do end end - def add_error(query, keys \\ [], message) do - keys = List.wrap(keys) - query = new(query) + @doc """ + Add an error to the errors list and mark the query as invalid. - message = - if is_binary(message) do - string_path = - case keys do - [key] -> to_string(key) - keys -> Enum.join(keys, ".") - end + See `Ash.Error.to_ash_error/3` for more on supported values for `error` - "#{string_path}: #{message}" - else - message - end + ## Inconsistencies + The `path` argument is the second argument here, but the third argument + in `Ash.ActionInput.add_error/2` and `Ash.Changeset.add_error/2`. + This will be fixed in 4.0. + """ + @spec add_error(t(), path :: Ash.Error.path_input(), Ash.Error.error_input()) :: t() + @spec add_error(t(), Ash.Error.error_input()) :: t() + def add_error(query, path \\ [], error) do + path = List.wrap(path) + query = new(query) - message + error |> Ash.Error.to_ash_error() + |> Ash.Error.set_path(path) |> case do errors when is_list(errors) -> - errors = - Enum.map(errors, fn error -> - Map.update(error, :path, keys, &(keys ++ List.wrap(&1))) - end) - %{query | errors: query.errors ++ errors, valid?: false} error -> - error = Map.update(error, :path, keys, &(keys ++ List.wrap(&1))) %{query | errors: [error | query.errors], valid?: false} end end