defmodule Ash.ActionInput do
@moduledoc """
Input for a custom action
"""
alias Ash.Error.Action.InvalidArgument
defstruct [
:action,
:api,
:resource,
arguments: %{},
params: %{},
context: %{},
valid?: true,
errors: []
]
@type t :: %__MODULE__{
arguments: map(),
params: map(),
action: Ash.Resource.Actions.Action.t(),
resource: Ash.Resource.t(),
context: map(),
api: Ash.Api.t(),
valid?: boolean()
}
@doc """
Creates a new input for a generic action
"""
@spec for_action(
resource_or_input :: Ash.Resource.t() | t(),
action :: atom,
params :: map,
opts :: Keyword.t()
) :: t()
def for_action(resource_or_input, action, params, opts \\ []) do
input =
case resource_or_input do
resource when is_atom(resource) ->
action = Ash.Resource.Info.action(resource, action)
%__MODULE__{resource: resource, action: action}
input ->
input
end
{input, _opts} = Ash.Actions.Helpers.add_process_context(input.api, input, opts)
cast_params(input, params)
end
@doc "Set an argument value"
@spec set_argument(input :: t(), name :: atom, value :: term()) :: t()
def set_argument(input, argument, value) do
if input.action do
argument =
Enum.find(
input.action.arguments,
&(&1.name == argument || to_string(&1.name) == argument)
)
if argument do
with {:ok, casted} <-
Ash.Type.Helpers.cast_input(argument.type, value, argument.constraints, input),
{:constrained, {:ok, casted}, argument} when not is_nil(casted) <-
{:constrained,
Ash.Type.apply_constraints(argument.type, casted, argument.constraints),
argument} do
%{input | arguments: Map.put(input.arguments, argument.name, casted)}
else
{:constrained, {:ok, nil}, _argument} ->
%{input | arguments: Map.put(input.arguments, argument.name, nil)}
{:constrained, {:error, error}, argument} ->
input = %{
input
| arguments: Map.put(input.arguments, argument.name, value)
}
add_invalid_errors(value, input, argument, error)
{:error, error} ->
input = %{
input
| arguments: Map.put(input.arguments, argument.name, value)
}
add_invalid_errors(value, input, argument, error)
end
else
%{input | arguments: Map.put(input.arguments, argument, value)}
end
else
%{input | arguments: Map.put(input.arguments, argument, value)}
end
end
@doc """
Deep merges the provided map into the input context that can be used later
Do not use the `private` key in your custom context, as that is reserved for internal use.
"""
@spec set_context(t(), map | nil) :: t()
def set_context(input, nil), do: input
def set_context(input, map) do
%{input | context: Ash.Helpers.deep_merge_maps(input.context, map)}
end
defp cast_params(input, params) do
input = %{
input
| params: Map.merge(input.params, Enum.into(params, %{}))
}
Enum.reduce(params, input, fn {name, value}, input ->
if has_argument?(input.action, name) do
set_argument(input, name, value)
else
input
end
end)
end
defp has_argument?(action, name) when is_atom(name) do
Enum.any?(action.arguments, &(&1.private? == false && &1.name == name))
end
defp has_argument?(action, name) when is_binary(name) do
Enum.any?(action.arguments, &(&1.private? == false && to_string(&1.name) == name))
end
defp add_invalid_errors(value, input, attribute, message) do
messages =
if Keyword.keyword?(message) do
[message]
else
List.wrap(message)
end
Enum.reduce(messages, input, fn message, input ->
if is_exception(message) do
error =
message
|> Ash.Error.to_ash_error()
errors =
case error do
%class{errors: errors}
when class in [
Ash.Error.Invalid,
Ash.Error.Unknown,
Ash.Error.Forbidden,
Ash.Error.Framework
] ->
errors
error ->
[error]
end
Enum.reduce(errors, input, fn error, input ->
add_error(input, Ash.Error.set_path(error, attribute.name))
end)
else
opts = Ash.Type.Helpers.error_to_exception_opts(message, attribute)
Enum.reduce(opts, input, fn opts, input ->
error =
InvalidArgument.exception(
value: value,
field: Keyword.get(opts, :field),
message: Keyword.get(opts, :message),
vars: opts
)
error =
if opts[:path] do
Ash.Error.set_path(error, opts[:path])
else
error
end
add_error(input, error)
end)
end
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()
def add_error(input, errors, path \\ [])
def add_error(input, errors, path) when is_list(errors) do
if Keyword.keyword?(errors) do
errors
|> to_change_errors()
|> Ash.Error.set_path(path)
|> handle_error(input)
else
Enum.reduce(errors, input, &add_error(&2, &1, path))
end
end
def add_error(input, error, path) when is_binary(error) do
add_error(
input,
InvalidArgument.exception(message: error),
path
)
end
def add_error(input, error, path) do
error
|> Ash.Error.set_path(path)
|> handle_error(input)
end
defp handle_error(error, input) do
%{input | valid?: false, errors: [error | input.errors]}
end
defp to_change_errors(keyword) do
errors =
if keyword[:fields] && keyword[:fields] != [] do
Enum.map(keyword[:fields], fn field ->
InvalidArgument.exception(
field: field,
message: keyword[:message],
value: keyword[:value],
vars: keyword
)
end)
else
InvalidArgument.exception(
field: keyword[:field],
message: keyword[:message],
value: keyword[:value],
vars: keyword
)
end
if keyword[:path] do
Enum.map(errors, &Ash.Error.set_path(&1, keyword[:path]))
else
errors
end
end
end