# SPDX-FileCopyrightText: 2019 ash contributors # # SPDX-License-Identifier: MIT defmodule Ash.ActionInput do @moduledoc """ Input for a custom action Much like an `Ash.Query` and `Ash.Changeset` are used to provide inputs into CRUD actions, this struct provides the inputs required to execute a generic action. """ alias Ash.Error.Action.InvalidArgument require Ash.Tracer defstruct [ :action, :domain, :resource, :tenant, :to_tenant, invalid_keys: MapSet.new(), arguments: %{}, params: %{}, context: %{}, valid?: true, errors: [], before_action: [], after_action: [], before_transaction: [], after_transaction: [], around_transaction: [], load: [] ] @typedoc """ Function type for before action hooks. Receives an action input and returns a modified action input, optionally with notifications. """ @type before_action_fun :: (t -> t | {t, %{notifications: [Ash.Notifier.Notification.t()]}}) @typedoc """ Function type for after action hooks. Receives the action input and the result of the action, and can return the result optionally with notifications, or an error. """ @type after_action_fun :: (t, term -> :ok | {:ok, [Ash.Notifier.Notification.t()]} | {:ok, term} | {:ok, term, [Ash.Notifier.Notification.t()]} | {:error, any}) @typedoc """ Function type for before transaction hooks. Receives an action input and returns a modified action input or an error. """ @type before_transaction_fun :: (t -> t | {:error, any}) @typedoc """ Function type for after transaction hooks. Receives the action input and the result of the transaction, and returns the result (potentially modified) or an error. """ @type after_transaction_fun :: (t, :ok | {:ok, term} | {:error, any} -> :ok | {:ok, term} | {:error, any}) @typedoc """ Function type for around transaction hooks. Receives an action input and a callback function that executes the transaction, and returns the result of calling the callback or an error. """ @type around_transaction_fun :: (t, (t -> :ok | {:ok, term} | {:error, any}) -> :ok | {:ok, term} | {:error, any}) @typedoc """ An action input struct for generic (non-CRUD) actions. Contains all the information needed to execute a generic action including arguments, context, tenant information, validation state, and lifecycle hooks. Built using `for_action/4` and modified with functions like `set_argument/3` and `set_context/2`. """ @type t :: %__MODULE__{ arguments: map(), params: map(), tenant: term(), action: Ash.Resource.Actions.Action.t() | nil, resource: Ash.Resource.t(), invalid_keys: MapSet.t(), context: map(), domain: Ash.Domain.t() | nil, valid?: boolean(), errors: [Ash.Error.t()], before_action: [before_action_fun], after_action: [after_action_fun], before_transaction: [before_transaction_fun], after_transaction: [after_transaction_fun], around_transaction: [around_transaction_fun], load: keyword(keyword) } @doc """ Creates a new action input from a resource. This creates a basic action input struct that can be used as a starting point for building inputs for generic actions. Use `for_action/4` to create an input bound to a specific action. ## Examples # Create a new action input for a resource iex> Ash.ActionInput.new(MyApp.Post) %Ash.ActionInput{resource: MyApp.Post, domain: nil, ...} # Usually you'll want to use for_action/4 instead iex> MyApp.Post |> Ash.ActionInput.for_action(:send_notification, %{message: "Hello"}) %Ash.ActionInput{action: %{name: :send_notification}, arguments: %{message: "Hello"}, ...} ## See also - `for_action/4` for creating action-specific inputs - `set_argument/3` for adding arguments - `set_context/2` for adding context """ @spec new(Ash.Resource.t(), Ash.Domain.t() | nil) :: t def new(resource, domain \\ nil) do %__MODULE__{resource: resource, domain: domain} end @for_action_opts [ domain: [ type: {:spark, Ash.Domain}, doc: "The domain to use for the action. The resource's domain is used by default." ], context: [ type: :map, doc: "Context to set on the action input.", default: %{} ], authorize?: [ type: {:or, [:boolean, {:literal, nil}]}, doc: "Whether or not to run authorization on the action. Default behavior of this option is controlled by the domain." ], tenant: [ type: :any, doc: "The tenant to use for the action." ], scope: [ type: :any, doc: "A value that implements the `Ash.Scope.ToOpts` protocol, for passing around actor/tenant/context in a single value. See `Ash.Scope.ToOpts` for more." ], actor: [ type: :any, doc: "The actor performing the action" ], skip_unknown_inputs: [ type: {:wrap_list, {:or, [:atom, :string]}}, doc: "A list of unknown inputs to skip. Use `:*` to skip all unknown inputs." ], tracer: [ type: :any, doc: "A tracer or list of tracers to trace action execution." ], private_arguments: [ type: :map, default: %{}, doc: "A list of private arguments to be set before the action is invoked." ], load: [ type: :any, doc: "A load statement to apply on the resulting records after the action is invoked." ] ] for_action_opts = @for_action_opts defmodule Opts do @moduledoc false use Spark.Options.Validator, schema: for_action_opts end @doc """ Creates a new input for a generic action. This is the primary way to create action inputs for generic actions. It validates the action exists, sets up the input with proper defaults, and validates any provided arguments according to the action's argument definitions. ## Examples # Create input for a simple action iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:send_notification, %{message: "Hello"}) %Ash.ActionInput{action: %{name: :send_notification}, arguments: %{message: "Hello"}, ...} # Create input with options iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:complex_action, %{data: "test"}, ...> actor: current_user, authorize?: true) %Ash.ActionInput{arguments: %{data: "test"}, ...} # Create input and then modify it iex> input = MyApp.Post ...> |> Ash.ActionInput.for_action(:example, %{}) ...> |> Ash.ActionInput.set_context(%{source: "api"}) iex> input.action.name :example ## Options #{Opts.docs()} ## See also - `new/2` for creating basic inputs - `set_argument/3` for adding arguments after creation - `Ash.run_action/2` for executing the action with the input - `d:Ash.Resource.Dsl.actions.action` for defining generic actions - [Generic Actions Guide](/documentation/topics/actions/generic-actions.md) for understanding generic actions - [Actions Guide](/documentation/topics/actions/actions.md) for general action concepts """ @doc spark_opts: [{4, @for_action_opts}] @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 with {:ok, opts} <- Opts.validate(opts), {:ok, input} <- set_action_for_input(resource_or_input, action), opts <- Opts.to_options(opts), {:ok, input} <- set_domain_for_input(input, opts) do {input, _opts} = Ash.Actions.Helpers.set_context_and_get_opts(input.domain, input, opts) input = Enum.reduce(opts[:private_arguments] || %{}, input, fn {k, v}, input -> Ash.ActionInput.set_private_argument(input, k, v) end) input |> cast_params(params, opts) |> set_defaults() |> require_arguments() |> run_preparations_and_validations(opts) |> load(opts[:load]) else {:error, reason} -> raise Ash.Error.to_error_class(reason) end end defp set_action_for_input(%Ash.ActionInput{resource: resource} = input, action_name) when is_atom(resource) do case Ash.Resource.Info.action(resource, action_name) do nil -> {:error, Ash.Error.Invalid.NoSuchAction.exception( resource: resource, action: action_name, type: :action )} action when action.type == :action -> {:ok, %{input | action: action}} action -> {:error, Ash.Error.Invalid.NoSuchAction.exception( resource: resource, action: action, type: :action )} end end defp set_action_for_input(resource, action) when is_atom(resource) do resource |> new() |> set_action_for_input(action) end defp set_domain_for_input(input, opts) do domain = input.domain || opts[:domain] || Ash.Resource.Info.domain(input.resource) || Ash.Actions.Helpers.maybe_embedded_domain(input.resource) if domain do {:ok, %{input | domain: domain}} else {:error, ArgumentError.exception( "Could not determine domain for action. Provide the `domain` option or configure a domain in the resource directly." )} end end @doc """ Sets the tenant to use when calling the action. In multitenant applications, this configures which tenant's data the action should operate on. The tenant value is used for data isolation and access control. ## Examples # Set tenant using a string identifier iex> MyApp.Post ...> |> Ash.ActionInput.new() ...> |> Ash.ActionInput.set_tenant("org_123") ...> |> then(& &1.tenant) "org_123" # Set tenant using a struct that implements Ash.ToTenant iex> org = %MyApp.Organization{id: 456} iex> input = MyApp.Post ...> |> Ash.ActionInput.for_action(:send_notification, %{}) ...> |> Ash.ActionInput.set_tenant(org) iex> input.tenant == org true # Use with action execution iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:cleanup, %{}) ...> |> Ash.ActionInput.set_tenant("tenant_456") ...> |> Ash.run_action() ## See also - `for_action/4` for setting tenant when creating inputs - `Ash.ToTenant` protocol for custom tenant conversion - `set_context/2` for adding tenant to action context """ @spec set_tenant(t(), Ash.ToTenant.t()) :: t() def set_tenant(input, tenant) do %{input | tenant: tenant, to_tenant: Ash.ToTenant.to_tenant(tenant, input.resource)} end @doc """ Calls the provided load statement on the result of the action at the very end of the action. This effectively will call `Ash.load/3` on the result of the action after all hooks, and assumes the result of the action will be compatible with said load call. ## Examples # Load a single relationship iex> input = Ash.ActionInput.for_action(MyApp.Post, :publish, %{}) iex> input = Ash.ActionInput.load(input, :comments) iex> input.load [:comments] # Load multiple relationships iex> input = Ash.ActionInput.for_action(user, :deactivate, %{reason: "user request"}) iex> input = Ash.ActionInput.load(input, [:posts, :profile]) iex> input.load [:posts, :profile] # Load nested relationships iex> input = Ash.ActionInput.for_action(MyApp.Comment, :approve, %{approved_by: "admin"}) iex> input = Ash.ActionInput.load(input, [post: [:author, :comments]]) iex> input.load [[post: [:author, :comments]]] # Chain multiple load calls (they accumulate) iex> input = Ash.ActionInput.for_action(post, :archive, %{}) iex> input = input ...> |> Ash.ActionInput.load(:author) ...> |> Ash.ActionInput.load(:comments) iex> input.load [:author, :comments] ## See also - `loading?/2` for checking if something is being loaded - `Ash.Changeset.load/2` for loading in changesets - `Ash.Query.load/2` for loading in queries """ @spec load(t(), term()) :: t() def load(input, load) do query = input.resource |> Ash.Query.new() |> Map.put(:errors, []) |> Ash.Query.load(input.load) |> Ash.Query.load(load) input = %{ input | load: Enum.concat(input.load || [], List.wrap(load)) } Enum.reduce(query.errors, input, &add_error(&2, &1)) end @doc """ Returns true if the field/relationship or path to field/relationship is being loaded at the very end of the action. Note that this is only in regards to the `:load` option (See `load/2`). This function does not indicate anything about the loading behavior of the action's run function. It accepts an atom or a list of atoms, which is treated for as a "path", i.e: Resource |> Ash.ActionInput.load(friends: [enemies: [:score]]) |> Ash.ActionInput.loading?([:friends, :enemies, :score]) iex> true Resource |> Ash.ActionInput.load(friends: [enemies: [:score]]) |> Ash.ActionInput.loading?([:friends, :score]) iex> false Resource |> Ash.ActionInput.load(friends: [enemies: [:score]]) |> Ash.ActionInput.loading?(:friends) iex> true """ def loading?(input, path) do input.resource |> Ash.Query.new() |> Ash.Query.load(input.load) |> Ash.Query.loading?(path) end defp require_arguments(input) do input.action.arguments |> Enum.filter(&(&1.allow_nil? == false)) |> Enum.reduce(input, fn argument, input -> case fetch_argument(input, argument.name) do {:ok, value} when not is_nil(value) -> input _ -> if argument.name in input.invalid_keys do input else add_error( input, Ash.Error.Changes.Required.exception( resource: input.resource, field: argument.name, type: :argument ) ) end end end) end defp set_defaults(input) do input.action.arguments |> Enum.reject(&is_nil(&1.default)) |> Enum.reduce(input, fn argument, input -> case fetch_argument(input, argument.name) do {:ok, value} when not is_nil(value) -> input _ -> if argument.name in input.invalid_keys do input else set_argument(input, argument.name, default(argument)) end end end) end defp default(%{default: {mod, func, args}}), do: apply(mod, func, args) defp default(%{default: function}) when is_function(function, 0), do: function.() defp default(%{default: value}), do: value @doc """ Gets the value of an argument provided to the input. Returns the argument value if found, or `nil` if not found. Arguments are validated and cast according to the action's argument definitions when set. ## Examples # Get an argument that exists iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:send_email, %{recipient: "user@example.com"}) ...> |> Ash.ActionInput.get_argument(:recipient) "user@example.com" # Get an argument that doesn't exist returns nil iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:send_email, %{}) ...> |> Ash.ActionInput.get_argument(:missing_arg) nil # Arguments can be accessed by string or atom key iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:example, %{"message" => "hello"}) ...> |> Ash.ActionInput.get_argument(:message) "hello" ## See also - `fetch_argument/2` for safer argument access with explicit error handling - `set_argument/3` for setting argument values - `for_action/4` for providing initial arguments """ @spec get_argument(t, atom | String.t()) :: term def get_argument(input, argument) when is_atom(argument) or is_binary(argument) do case fetch_argument(input, argument) do {:ok, value} -> value :error -> nil end end @doc """ Fetches the value of an argument provided to the input. Returns `{:ok, value}` if the argument exists, or `:error` if not found. This is the safer alternative to `get_argument/2` when you need to distinguish between a `nil` value and a missing argument. ## Examples # Fetch an argument that exists iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:send_notification, %{priority: :high}) ...> |> Ash.ActionInput.fetch_argument(:priority) {:ok, :high} # Fetch an argument that doesn't exist iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:send_notification, %{}) ...> |> Ash.ActionInput.fetch_argument(:missing_arg) :error # Distinguish between nil and missing arguments iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:example, %{optional_field: nil}) ...> |> Ash.ActionInput.fetch_argument(:optional_field) {:ok, nil} # Use in conditional logic iex> input = MyApp.Post |> Ash.ActionInput.for_action(:process, %{}) iex> case Ash.ActionInput.fetch_argument(input, :mode) do ...> {:ok, mode} -> "Processing in \#{mode} mode" ...> :error -> "Using default processing mode" ...> end "Using default processing mode" ## See also - `get_argument/2` for simpler argument access - `set_argument/3` for setting argument values - `for_action/4` for providing initial arguments """ @spec fetch_argument(t, atom | String.t()) :: {:ok, term()} | :error def fetch_argument(input, argument) when is_atom(argument) or is_binary(argument) do with :error <- Map.fetch(input.arguments, argument) do Map.fetch(input.arguments, to_string(argument)) end end @doc """ Sets an argument value on the action input. The argument value is validated and cast according to the action's argument definition. If validation fails, errors will be added to the input and it will be marked as invalid. ## Examples # Set a simple argument iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:send_notification, %{}) ...> |> Ash.ActionInput.set_argument(:message, "Hello World") ...> |> Ash.ActionInput.get_argument(:message) "Hello World" # Set multiple arguments by chaining iex> input = MyApp.Post ...> |> Ash.ActionInput.for_action(:complex_action, %{}) ...> |> Ash.ActionInput.set_argument(:priority, :high) ...> |> Ash.ActionInput.set_argument(:batch_size, 100) iex> Ash.ActionInput.get_argument(input, :priority) :high # Arguments are validated according to type iex> input = MyApp.Post ...> |> Ash.ActionInput.for_action(:schedule_job, %{}) ...> |> Ash.ActionInput.set_argument(:run_at, ~U[2024-01-01 10:00:00Z]) iex> input.valid? true ## See also - `get_argument/2` for retrieving argument values - `set_private_argument/3` for setting private arguments - `for_action/4` for providing initial arguments """ @spec set_argument(input :: t(), name :: atom | String.t(), 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 value <- Ash.Type.Helpers.handle_indexed_maps(argument.type, value), constraints <- Ash.Type.include_source(argument.type, input, argument.constraints), {:ok, casted} <- Ash.Type.cast_input(argument.type, value, constraints), {:constrained, {:ok, casted}, argument} when not is_nil(casted) <- {:constrained, Ash.Type.apply_constraints(argument.type, casted, 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 end else input end end @doc """ Deletes one or more arguments from the subject. ## Parameters * `subject` - The subject to delete arguments from * `arguments` - Single argument name or list of argument names to delete """ @spec delete_argument( input :: t(), argument_or_arguments :: atom | String.t() | list(atom | String.t()) ) :: t() def delete_argument(input, argument_or_arguments) do argument_or_arguments |> List.wrap() |> Enum.reduce(input, fn argument, input -> %{input | arguments: Map.delete(input.arguments, argument)} end) end @doc """ Sets a private argument value on the action input. Private arguments are not exposed in the public API and can only be set internally. This function will only work for arguments marked as `public?: false` in the action definition. ## Examples # Set a private argument (assuming :internal_flag is private) iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:example, %{}) ...> |> Ash.ActionInput.set_private_argument(:internal_flag, true) ...> |> Ash.ActionInput.get_argument(:internal_flag) true # Attempting to set a public argument as private will error iex> input = MyApp.Post ...> |> Ash.ActionInput.for_action(:example, %{}) ...> |> Ash.ActionInput.set_private_argument(:public_arg, "value") iex> input.valid? false # Use in action implementations for internal state iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:complex_workflow, %{data: "user_data"}) ...> |> Ash.ActionInput.set_private_argument(:workflow_step, 1) ## See also - `set_argument/3` for setting public arguments - `get_argument/2` for retrieving argument values - Action argument definitions with `public?: false` """ @spec set_private_argument(input :: t(), name :: atom, value :: term()) :: t() def set_private_argument(input, name, value) do argument = Enum.find( input.action.arguments, &(&1.name == name || to_string(&1.name) == name) ) cond do is_nil(argument) -> input argument.public? -> add_invalid_errors( value, input, argument, "can't set public arguments with set_private_argument/3" ) true -> set_argument(input, name, value) end end @doc """ Deep merges the provided map into the input context. Context is used to pass additional information through the action pipeline that can be accessed by action implementations, changes, and validations. The context is merged deeply, so nested maps will be combined rather than replaced. Do not use the `private` key in your custom context, as that is reserved for internal use. ## Examples # Set simple context values iex> MyApp.Post ...> |> Ash.ActionInput.new() ...> |> Ash.ActionInput.set_context(%{source: "api", user_id: 123}) ...> |> then(& &1.context.source) "api" # Context is merged deeply iex> input = MyApp.Post ...> |> Ash.ActionInput.new() ...> |> Ash.ActionInput.set_context(%{metadata: %{version: 1}}) ...> |> Ash.ActionInput.set_context(%{metadata: %{trace_id: "abc123"}}) iex> input.context.metadata %{version: 1, trace_id: "abc123"} # Use context in action implementations iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:process_data, %{data: "test"}) ...> |> Ash.ActionInput.set_context(%{ ...> request_id: "req_456", ...> feature_flags: %{new_algorithm: true} ...> }) ## See also - `for_action/4` for setting context when creating inputs - Action implementations can access context for custom logic - `set_tenant/2` for tenant-specific context """ @spec set_context(t(), map | nil) :: t() def set_context(input, nil), do: input def set_context(input, map) do %{ input | context: input.context |> Ash.Helpers.deep_merge_maps(map) |> then(&Ash.Helpers.deep_merge_maps(&1, map[:shared] || %{})) } end defp cast_params(input, params, opts) do input = %{ input | params: Map.merge(input.params, Enum.into(params, %{})) } skip_unknown_inputs = List.wrap(opts[:skip_unknown_inputs] || input.action.skip_unknown_inputs) Enum.reduce(params, input, fn {name, value}, input -> cond do has_argument?(input.action, name) -> set_argument(input, name, value) match?("_" <> _, name) -> input :* in skip_unknown_inputs -> input name in skip_unknown_inputs -> input true -> error = Ash.Error.Invalid.NoSuchInput.exception( resource: input.resource, action: input.action.name, input: name, inputs: Enum.map(input.action.arguments, & &1.name) ) add_error(input, Ash.Error.set_path(error, name)) end end) end defp has_argument?(action, name) when is_atom(name) do Enum.any?(action.arguments, &(&1.public? && &1.name == name)) end defp has_argument?(action, name) when is_binary(name) do Enum.any?(action.arguments, &(&1.public? && to_string(&1.name) == name)) end defp add_invalid_errors(value, input, attribute, message) do input = %{input | invalid_keys: MapSet.put(input.invalid_keys, attribute.name)} 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 errors list and marks the action input as invalid. This function allows you to add validation errors or other issues to the action input. Once an error is added, the input will be marked as invalid and action execution will be prevented. ## Examples # Add a simple string error iex> input = MyApp.Post ...> |> Ash.ActionInput.for_action(:send_notification, %{}) ...> |> Ash.ActionInput.add_error("Missing required configuration") iex> input.valid? false # Add an error with a specific path iex> input = MyApp.Post ...> |> Ash.ActionInput.for_action(:process_data, %{}) ...> |> Ash.ActionInput.add_error("Invalid format", [:data, :format]) iex> input.errors |> List.first() |> Map.get(:path) [:data, :format] # Add multiple errors iex> input = MyApp.Post ...> |> Ash.ActionInput.for_action(:complex_action, %{}) ...> |> Ash.ActionInput.add_error(["Error 1", "Error 2"]) iex> length(input.errors) 2 # Add structured error with keyword list iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:validate_input, %{}) ...> |> Ash.ActionInput.add_error(field: :email, message: "is invalid") ## See also - `Ash.Error.to_ash_error/3` for more on supported error values - Action implementations can use this to add custom validation errors - `set_argument/3` automatically adds errors for invalid argument values """ @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, [], _path) do input end 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 @doc """ Adds a before_action hook to the action input. Before action hooks are called with the action input and can modify it before the action executes. They can also add errors to halt processing or return notifications to be processed later. ## Examples # Validate arguments before action iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:send_notification, %{message: "Hello"}) ...> |> Ash.ActionInput.before_action(fn input -> ...> if String.length(input.arguments.message) > 100 do ...> Ash.ActionInput.add_error(input, "Message too long") ...> else ...> input ...> end ...> end) # Set computed arguments iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:process_data, %{data: "test"}) ...> |> Ash.ActionInput.before_action(fn input -> ...> Ash.ActionInput.set_argument(input, :processed_at, DateTime.utc_now()) ...> end) # Return notifications iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:audit_action, %{}) ...> |> Ash.ActionInput.before_action(fn input -> ...> notification = %Ash.Notifier.Notification{ ...> resource: input.resource, ...> action: input.action, ...> data: %{audit: "before_action"} ...> } ...> {input, %{notifications: [notification]}} ...> end) ## Options - `prepend?` - If `true`, adds the hook to the beginning of the list instead of the end ## See also - `after_action/2` for hooks that run after the action completes - `for_action/4` for creating action inputs - `add_error/2` for adding validation errors in hooks """ @spec before_action( input :: t(), fun :: before_action_fun(), opts :: Keyword.t() ) :: t() def before_action(input, func, opts \\ []) do if opts[:prepend?] do %{input | before_action: [func | input.before_action]} else %{input | before_action: input.before_action ++ [func]} end end @doc """ Adds an after_action hook to the action input. After action hooks are called with the action input and the result returned from the action. They can modify the result, perform side effects, or return errors to halt processing. The hook can return notifications alongside the result. > #### Actions without a return type {: .tip} > > After action hooks will work for generic actions without a return type, > however they will receive `nil` as their `result` argument and are > expected to return > `:ok | {:ok, [Ash.Notifier.Notification.t()]} | {:error, term}`. ## Examples # Transform the result after action iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:calculate_stats, %{data: [1, 2, 3]}) ...> |> Ash.ActionInput.after_action(fn input, result -> ...> enhanced_result = Map.put(result, :calculated_at, DateTime.utc_now()) ...> {:ok, enhanced_result} ...> end) # Log successful actions iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:important_action, %{}) ...> |> Ash.ActionInput.after_action(fn inp, result -> ...> Logger.info("Action completed successfully") ...> {:ok, result} ...> end) # Return notifications iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:notify_users, %{message: "Hello"}) ...> |> Ash.ActionInput.after_action(fn input, result -> ...> notification = %Ash.Notifier.Notification{ ...> resource: input.resource, ...> action: input.action, ...> data: result ...> } ...> {:ok, result, [notification]} ...> end) # Handle errors iex> MyApp.Post ...> |> Ash.ActionInput.for_action(:risky_action, %{}) ...> |> Ash.ActionInput.after_action(fn input, result -> ...> if is_error_result?(result) do ...> {:error, "Action failed with custom error"} ...> else ...> {:ok, result} ...> end ...> end) ## See also - `before_action/3` for hooks that run before the action executes - `for_action/4` for creating action inputs - `Ash.run_action/2` for executing the action with the input """ @spec after_action( input :: t(), fun :: after_action_fun(), opts :: Keyword.t() ) :: t() def after_action(input, func, opts \\ []) do if opts[:prepend?] do %{input | after_action: [func | input.after_action]} else %{input | after_action: input.after_action ++ [func]} end end @doc """ Adds a before transaction hook to the action input. Before transaction hooks are executed before the transaction begins (if the action is transactional). They can modify the action input or halt execution by returning an error. ## Examples # Add logging before transaction iex> input ...> |> Ash.ActionInput.before_transaction(fn input -> ...> IO.puts("Starting transaction for action") ...> input ...> end) ## See also - `after_transaction/2` for hooks that run after the transaction - `around_transaction/2` for hooks that wrap the entire transaction - `before_action/3` for hooks that run before the action (inside transaction) """ @spec before_transaction( input :: t(), fun :: before_transaction_fun(), opts :: Keyword.t() ) :: t() def before_transaction(input, func, opts \\ []) do if opts[:prepend?] do %{input | before_transaction: [func | input.before_transaction]} else %{input | before_transaction: input.before_transaction ++ [func]} end end @doc """ Adds an after transaction hook to the action input. After transaction hooks are executed after the transaction completes, regardless of success or failure. They receive both the input and the transaction result, and can modify the result. > #### Actions without a return type {: .tip} > > After transaction hooks will work for generic actions without a return > type, however they will receive `:ok | {:error, term}` as their `result` > argument and are expected to return `:ok | {:error, term}`. ## Examples # Add cleanup after transaction iex> input ...> |> Ash.ActionInput.after_transaction(fn input, result -> ...> cleanup_resources() ...> result ...> end) ## See also - `before_transaction/2` for hooks that run before the transaction - `around_transaction/2` for hooks that wrap the entire transaction - `after_action/2` for hooks that run after the action (inside transaction) """ @spec after_transaction( input :: t(), fun :: after_transaction_fun(), opts :: Keyword.t() ) :: t() def after_transaction(input, func, opts \\ []) do if opts[:prepend?] do %{input | after_transaction: [func | input.after_transaction]} else %{input | after_transaction: input.after_transaction ++ [func]} end end @doc """ Adds an around transaction hook to the action input. Around transaction hooks wrap the entire transaction execution. They receive a callback function that they must call to execute the transaction, allowing them to add logic both before and after the transaction. > #### Actions without a return type {: .tip} > > Around transaction hooks will work for generic actions without a return > type, however they will receive `:ok` as the result of the callback > instead of `{:ok, result}`. They are expected to return > `:ok | {:error, any}`. ## Examples # Add retry logic around transaction iex> input ...> |> Ash.ActionInput.around_transaction(fn input, callback -> ...> case callback.(input) do ...> {:ok, result} -> {:ok, result} ...> {:error, %{retryable?: true}} -> callback.(input) # Retry once ...> error -> error ...> end ...> end) ## See also - `before_transaction/2` for hooks that run before the transaction - `after_transaction/2` for hooks that run after the transaction - `before_action/3` and `after_action/2` for hooks that run inside the transaction """ @spec around_transaction( input :: t(), fun :: around_transaction_fun(), opts :: Keyword.t() ) :: t() def around_transaction(input, func, opts \\ []) do if opts[:prepend?] do %{input | around_transaction: [func | input.around_transaction]} else %{input | around_transaction: input.around_transaction ++ [func]} end end defguardp has_return?(input) when not is_nil(input.action.returns) defguardp has_no_return?(input) when is_nil(input.action.returns) defp run_preparations_and_validations(input, opts) do actor = opts[:actor] authorize? = opts[:authorize?] tracer = opts[:tracer] metadata = fn -> %{ domain: input.domain, resource: input.resource, resource_short_name: Ash.Resource.Info.short_name(input.resource), actor: actor, tenant: input.tenant, action: input.action && input.action.name, authorize?: authorize? } end # Get global validations unless skipped global_validations = if input.action.skip_global_validations? do [] else input.resource |> Ash.Resource.Info.validations() |> Enum.filter(&(:action in &1.on)) end # Run preparations and validations input.resource # 4.0 run global preparations after # action preparations |> Ash.Resource.Info.preparations(:action) |> Enum.concat(input.action.preparations || []) |> Enum.concat(global_validations) |> Enum.reduce(input, fn %{only_when_valid?: true}, %{valid?: false} = input -> input %{validation: {module, opts}} = validation, input -> if __MODULE__ not in module.supports(opts) do raise Ash.Error.Framework.UnsupportedSubject, subject: __MODULE__, module: module end validate(input, validation, tracer, metadata, actor) %{preparation: {module, opts}} = preparation, input -> if __MODULE__ not in module.supports(opts) do raise Ash.Error.Framework.UnsupportedSubject, subject: __MODULE__, module: module end run_preparation(preparation, input, actor, authorize?, tracer, metadata) end) end defp validate(input, validation, tracer, metadata, actor) do if validation.before_action? do before_action(input, fn input -> if validation.only_when_valid? and not input.valid? do input else do_validation(input, validation, tracer, metadata, actor) end end) else if validation.only_when_valid? and not input.valid? do input else do_validation(input, validation, tracer, metadata, actor) end end end defp do_validation(input, validation, tracer, metadata, actor) do context = %{ actor: input.context[:private][:actor], tenant: input.tenant, source_context: input.context, authorize?: input.context[:private][:authorize?] || false, tracer: input.context[:private][:tracer] } if Enum.all?(validation.where || [], fn {module, opts} -> if __MODULE__ not in module.supports(opts) do raise Ash.Error.Framework.UnsupportedSubject, subject: __MODULE__, module: module end opts = Ash.Expr.fill_template( opts, actor: actor, tenant: input.to_tenant, args: input.arguments, context: input.context ) case Ash.Resource.Validation.init(module, opts) do {:ok, opts} -> Ash.Resource.Validation.validate( module, input, opts, struct(Ash.Resource.Validation.Context, context) ) == :ok _ -> false end end) do Ash.Tracer.span :validation, fn -> "validate: #{inspect(validation.module)}" end, tracer do Ash.Tracer.telemetry_span [:ash, :validation], fn -> %{ resource_short_name: Ash.Resource.Info.short_name(input.resource), validation: inspect(validation.module) } end do Ash.Tracer.set_metadata(tracer, :validation, metadata) opts = Ash.Expr.fill_template( validation.opts, actor: actor, tenant: input.to_tenant, args: input.arguments, context: input.context ) with {:ok, opts} <- Ash.Resource.Validation.init(validation.module, opts), :ok <- Ash.Resource.Validation.validate( validation.module, input, opts, struct(Ash.Resource.Validation.Context, context) ) do input else {:error, error} when is_binary(error) -> add_error(input, validation.message || error) {:error, error} when is_exception(error) -> if validation.message do error = Ash.Error.override_validation_message(error, validation.message) add_error(input, error) else add_error(input, error) end {:error, errors} when is_list(errors) -> if validation.message do errors = Enum.map(errors, fn error -> Ash.Error.override_validation_message(error, validation.message) end) add_error(input, errors) else add_error(input, errors) end {:error, error} -> error = if Keyword.keyword?(error) do Keyword.put(error, :message, validation.message || error[:message]) else validation.message || error end add_error(input, error) _ when validation.message -> add_error(input, validation.message) :error -> add_error( input, Ash.Resource.Validation.describe(validation.module, validation.opts) ) end end end else input end end defp run_preparation( %{preparation: {module, opts}} = preparation, input, actor, authorize?, tracer, metadata ) do context = %{ actor: actor, tenant: input.tenant, source_context: input.context, authorize?: authorize? || false, tracer: tracer } if Enum.all?(preparation.where || [], fn {module, opts} -> if __MODULE__ not in module.supports(opts) do raise Ash.Error.Framework.UnsupportedSubject, subject: __MODULE__, module: module end opts = Ash.Expr.fill_template( opts, actor: actor, tenant: input.to_tenant, args: input.arguments, context: input.context ) case Ash.Resource.Validation.init(module, opts) do {:ok, opts} -> Ash.Resource.Validation.validate( module, input, opts, struct(Ash.Resource.Validation.Context, context) ) == :ok _ -> false end end) do Ash.Tracer.span :preparation, fn -> "prepare: #{inspect(module)}" end, tracer do Ash.Tracer.telemetry_span [:ash, :preparation], fn -> %{ resource_short_name: Ash.Resource.Info.short_name(input.resource), preparation: inspect(module) } end do Ash.Tracer.set_metadata(tracer, :preparation, metadata) {:ok, opts} = Ash.Resource.Preparation.init(module, opts) opts = Ash.Expr.fill_template( opts, actor: actor, tenant: input.to_tenant, args: input.arguments, context: input.context ) preparation_context = struct(Ash.Resource.Preparation.Context, context) Ash.Resource.Preparation.prepare(module, input, opts, preparation_context) end end else input end end @doc false def run_before_actions(%{before_action: []} = input), do: {input, %{notifications: []}} def run_before_actions(%{valid?: false} = input), do: {input, %{notifications: []}} def run_before_actions(input) do Enum.reduce_while( input.before_action, {input, %{notifications: []}}, fn before_action, {input, instructions} -> metadata = fn -> %{ domain: input.domain, resource: input.resource, resource_short_name: Ash.Resource.Info.short_name(input.resource), actor: input.context[:private][:actor], tenant: input.context[:private][:tenant], action: input.action && input.action.name, authorize?: input.context[:private][:authorize?] } end tracer = input.context[:private][:tracer] result = Ash.Tracer.span :before_action, "before_action", tracer do Ash.Tracer.set_metadata(tracer, :before_action, metadata) Ash.Tracer.telemetry_span [:ash, :before_action], metadata do before_action.(input) end end case result do {:error, error} -> {:halt, {:error, error}} {input, %{notifications: notifications}} -> cont = if input.valid? do :cont else :halt end {cont, {input, %{ instructions | notifications: List.wrap(instructions.notifications) ++ List.wrap(notifications) }}} %Ash.ActionInput{} = input -> cont = if input.valid? do :cont else :halt end {cont, {input, instructions}} other -> raise """ Invalid return value from before_action hook. Expected one of: * %Ash.ActionInput{} * {%Ash.ActionInput{}, %{notifications: [...]}} * {:error, error} Got: #{inspect(other)} """ end end ) |> case do {:error, error} -> {:error, error} {%{valid?: true} = input, instructions} -> {input, instructions} {%{valid?: false} = input, _instructions} -> {:error, Ash.Error.to_error_class(input.errors)} end end @doc false def run_after_actions(result, input, before_action_notifications) do Enum.reduce_while( input.after_action, {:ok, result, input, %{notifications: before_action_notifications}}, fn after_action, {:ok, result, input, acc} -> tracer = input.context[:private][:tracer] metadata = fn -> %{ domain: input.domain, resource: input.resource, resource_short_name: Ash.Resource.Info.short_name(input.resource), actor: input.context[:private][:actor], tenant: input.context[:private][:tenant], action: input.action && input.action.name, authorize?: input.context[:private][:authorize?] } end result = Ash.Tracer.span :after_action, "after_action", tracer do Ash.Tracer.set_metadata(tracer, :after_action, metadata) Ash.Tracer.telemetry_span [:ash, :after_action], metadata do after_action.(input, result) end end case result do {:ok, new_result, new_notifications} when has_return?(input) -> {:cont, {:ok, new_result, input, %{ acc | notifications: merge_after_action_notifications( input, new_result, acc.notifications, new_notifications ) }}} :ok when has_no_return?(input) -> {:cont, {:ok, nil, input, acc}} {:ok, new_notifications} when has_no_return?(input) and is_list(new_notifications) -> {:cont, {:ok, nil, input, %{ acc | notifications: merge_after_action_notifications( input, nil, acc.notifications, new_notifications ) }}} {:ok, new_result} when has_return?(input) -> {:cont, {:ok, new_result, input, acc}} {:error, error} -> {:halt, {:error, error}} other when has_return?(input) -> raise """ Invalid return value from after_action hook. Because this action has a return type I expected one of: * {:ok, result} * {:ok, result, notifications} * {:error, error} Got: #{inspect(other)} """ other when has_no_return?(input) -> raise """ Invalid return value from after_action hook. Because this action has no return type I expected one of: * :ok * {:ok, notifications} * {:error, error} Got: #{inspect(other)} """ end end ) end defp merge_after_action_notifications(input, result, old_notifications, new_notifications) do Enum.map( List.wrap(old_notifications) ++ List.wrap(new_notifications), fn notification -> %{ notification | resource: notification.resource || input.resource, action: notification.action || Ash.Resource.Info.action( input.resource, input.action.name, :action ), data: notification.data || result, actor: notification.actor || input.context[:private][:actor] } end ) end @doc false def run_before_transaction_hooks(%{before_transaction: []} = input) do {:ok, input} end def run_before_transaction_hooks(input) do Enum.reduce_while( input.before_transaction, {:ok, input}, fn before_transaction, {:ok, input} -> tracer = input.context[:private][:tracer] metadata = fn -> %{ domain: input.domain, resource: input.resource, resource_short_name: Ash.Resource.Info.short_name(input.resource), actor: input.context[:private][:actor], tenant: input.context[:private][:tenant], action: input.action && input.action.name, authorize?: input.context[:private][:authorize?] } end result = Ash.Tracer.span :before_transaction, "before_transaction", tracer do Ash.Tracer.set_metadata(tracer, :before_transaction, metadata) Ash.Tracer.telemetry_span [:ash, :before_transaction], metadata do before_transaction.(input) end end case result do {:error, error} -> {:halt, {:error, error}} %Ash.ActionInput{} = input -> cont = if input.valid? do :cont else :halt end {cont, {:ok, input}} other -> raise """ Invalid return value from before_transaction hook. Expected one of: * %Ash.ActionInput{} * {:error, error} Got: #{inspect(other)} """ end end ) end @doc false def run_after_transaction_hooks(result, %{after_transaction: []} = _input) do result end def run_after_transaction_hooks(result, input) do input.after_transaction |> Enum.reduce( result, fn after_transaction, result -> tracer = input.context[:private][:tracer] metadata = fn -> %{ domain: input.domain, resource: input.resource, resource_short_name: Ash.Resource.Info.short_name(input.resource), actor: input.context[:private][:actor], tenant: input.context[:private][:tenant], action: input.action && input.action.name, authorize?: input.context[:private][:authorize?] } end Ash.Tracer.span :after_transaction, "after_transaction", tracer do Ash.Tracer.set_metadata(tracer, :after_transaction, metadata) Ash.Tracer.telemetry_span [:ash, :after_transaction], metadata do after_transaction.(input, result) end end end ) |> case do :ok when has_no_return?(input) -> :ok {:ok, new_result} when has_return?(input) -> {:ok, new_result} {:error, error} -> {:error, error} other when has_return?(input) -> raise """ Invalid return value from after_transaction hook. Because this action has a return type I expected one of: * {:ok, term} * {:error, error} Got: #{inspect(other)} """ other when has_no_return?(input) -> raise """ Invalid return value from after_transaction hook. Because this action has no return type I expected one of: * :ok * {:error, error} Got: #{inspect(other)} """ end end @doc false def run_around_transaction_hooks(%{around_transaction: []} = input, func) do case func.(input) do :ok when has_no_return?(input) -> :ok {:ok, term} when has_return?(input) -> {:ok, term} {:error, error} -> {:error, error} other when has_no_return?(input) -> raise """ Invalid return value from around_transaction hook. Because this action has no return type, I expected one of: * :ok * {:error, error} Got: #{inspect(other)} """ other when has_return?(input) -> raise """ Invalid return value from around_transaction hook. Because this action has a return type, I expected one of: * {:ok, term} * {:error, error} Got: #{inspect(other)} """ end end def run_around_transaction_hooks(%{around_transaction: [around | rest]} = input, func) do around.(input, fn input -> run_around_transaction_hooks(%{input | around_transaction: rest}, func) end) end end