improvement: initial support for basic actions

This commit is contained in:
Zach Daniel 2023-05-02 01:54:53 -04:00
parent ba4e7b40ae
commit 49949ff58f
23 changed files with 876 additions and 10 deletions

View file

@ -2,6 +2,8 @@ spark_locals_without_parens = [
accept: 1,
access_type: 1,
action: 1,
action: 2,
action: 3,
allow: 1,
allow_expr?: 1,
allow_nil?: 1,
@ -169,6 +171,7 @@ spark_locals_without_parens = [
require_attributes: 1,
required?: 1,
returns: 1,
run: 1,
run_flow: 2,
run_flow: 3,
select: 1,

View file

@ -2,7 +2,14 @@
## Action Types
Ash has 4 action types `:read`, `:create`, `:update`, `:destroy`. The purpose of these action types is to provide expectations about what is required to run those actions, and what is returned from them.
Ash has 5 action types `:read`, `:create`, `:update`, `:destroy` and `:action`. The purpose of these action types is to provide expectations about what is required to run those actions, and what is returned from them.
### Basic Actions
The `:action` type is a special type of action that can do essentially whatever you want. We refer to it as a "basic" action, because there are no special rules about how it works, and minimal structure surrounding it.
A basic action takes arguments and returns a value. The struct used for building input for a basic action is `Ash.ActionInput`. For the rest of this document we will discuss the four main action types.
### Create/Read/Update/Destroy
The actions do not need to do _exactly_ what their action type implies however. Using manual actions, you can define a create action that actually updates something, or using the `soft?` option for `destroy` actions you can treat them as updates. The important part to consider is their interface. More action types may be added in the future.

242
lib/ash/action_input.ex Normal file
View file

@ -0,0 +1,242 @@
defmodule Ash.ActionInput do
@moduledoc """
Input for a custom action
"""
alias Ash.Error.Action.InvalidArgument
defstruct [:action, :api, :resource, arguments: %{}, params: %{}, context: %{}, valid?: true]
@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 basic 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 Exception.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

194
lib/ash/actions/action.ex Normal file
View file

@ -0,0 +1,194 @@
defmodule Ash.Actions.Action do
@moduledoc false
require Ash.Tracer
def run(api, input, opts) do
{input, opts} = Ash.Actions.Helpers.add_process_context(api, input, opts)
context =
Map.merge(input.context, %{
actor: opts[:actor],
tenant: opts[:tenant],
authorize?: opts[:authorize?],
api: opts[:api]
})
{module, run_opts} = input.action.run
Ash.Tracer.span :action,
Ash.Api.Info.span_name(
api,
input.resource,
input.action.name
),
opts[:tracer] do
metadata = %{
api: api,
resource: input.resource,
resource_short_name: Ash.Resource.Info.short_name(input.resource),
actor: opts[:actor],
tenant: opts[:tenant],
action: input.action.name,
authorize?: opts[:authorize?]
}
Ash.Tracer.set_metadata(opts[:tracer], :action, metadata)
Ash.Tracer.telemetry_span [:ash, Ash.Api.Info.short_name(api), :create],
metadata do
if input.action.transaction? do
resources =
input.resource
|> List.wrap()
|> Enum.concat(input.action.touches_resources)
|> Enum.uniq()
notify? =
if Process.get(:ash_started_transaction?) do
false
else
Process.put(:ash_started_transaction?, true)
true
end
try do
resources
|> Enum.reject(&Ash.DataLayer.in_transaction?/1)
|> Ash.DataLayer.transaction(fn ->
case authorize(api, opts[:actor], input) do
:ok ->
case module.run(input, run_opts, context) do
{:ok, result} ->
{:ok, result, []}
other ->
other
end
{:error, error} ->
{:error, error}
end
end)
|> case do
{:ok, {:ok, value, notifications}} ->
notifications =
if notify? && !opts[:return_notifications?] do
Enum.concat(
notifications || [],
Process.delete(:ash_notifications) || []
)
else
notifications || []
end
remaining = Ash.Notifier.notify(notifications)
Ash.Actions.Helpers.warn_missed!(input.resource, input.action, %{
resource_notifications: remaining
})
{:ok, value}
{:error, error} ->
{:error, error}
end
after
if notify? do
Process.delete(:ash_started_transaction?)
end
end
else
case authorize(api, opts[:actor], input) do
:ok ->
case module.run(input, run_opts, context) do
{:ok, result} ->
{:ok, result}
{:ok, result, notifications} ->
remaining = Ash.Notifier.notify(notifications)
Ash.Actions.Helpers.warn_missed!(input.resource, input.action, %{
resource_notifications: remaining
})
{:ok, result}
{:error, error} ->
{:error, error}
end
{:error, error} ->
{:error, error}
end
end
end
end
end
defp authorize(api, actor, input) do
input.resource
|> Ash.Resource.Info.authorizers()
|> Enum.reduce_while(
:ok,
fn authorizer, :ok ->
authorizer_state =
authorizer.initial_state(
actor,
input.resource,
input.action,
false
)
context = %{
api: api,
action_input: input,
query: nil,
changeset: nil
}
case authorizer.strict_check(authorizer_state, context) do
{:error, %{class: :forbidden} = e} when is_exception(e) ->
{:halt, {:error, e}}
{:error, error} ->
{:halt, {:error, error}}
{:authorized, _} ->
{:cont, :ok}
{:filter, _authorizer, filter} ->
raise """
Cannot use filter checks with basic actions
Received #{inspect(filter)} when authorizing #{inspect(input.resource)}.#{input.action.name}
"""
{:filter, filter} ->
raise """
Cannot use filter checks with basic actions
Received #{inspect(filter)} when authorizing #{inspect(input.resource)}.#{input.action.name}
"""
{:continue, _state} ->
raise """
Cannot use runtime checks with basic actions
Must use only simple checks or other checks that can be resolved without returning results #{inspect(input.resource)}.#{input.action.name}
"""
{:filter_and_continue, filter, _} ->
raise """
Cannot use filter checks with basic actions
Received #{inspect(filter)} when authorizing #{inspect(input.resource)}.#{input.action.name}
"""
:forbidden ->
{:halt, {:error, Ash.Authorizer.exception(authorizer, :forbidden, authorizer_state)}}
end
end
)
end
end

View file

@ -21,6 +21,9 @@ defmodule Ash.Actions.Helpers do
defp set_context(%{__struct__: Ash.Query} = query, context),
do: Ash.Query.set_context(query, context)
defp set_context(%{__struct__: Ash.ActionInput} = action_input, context),
do: Ash.ActionInput.set_context(action_input, context)
def add_process_context(api, query_or_changeset, opts) do
query_or_changeset = set_context(query_or_changeset, opts[:context] || %{})
api = api || query_or_changeset.api
@ -81,6 +84,11 @@ defmodule Ash.Actions.Helpers do
private_context = Map.new(Keyword.take(opts, [:actor, :authorize?]))
case query_or_changeset do
%{__struct__: Ash.ActionInput} ->
query_or_changeset
|> Ash.ActionInput.set_context(context)
|> Ash.ActionInput.set_context(%{private: private_context})
%{__struct__: Ash.Query} ->
query_or_changeset
|> Ash.Query.set_context(context)

View file

@ -708,6 +708,60 @@ defmodule Ash.Api do
]
]
@run_action_opts [
actor: [
type: :any,
doc: """
The actor for handling `^actor/1` templates, supplied to calculation context.
"""
],
tenant: [
type: :any,
doc: """
The tenant, supplied to calculation context.
"""
],
authorize?: [
type: :boolean,
doc: """
Wether or not the request should be authorized.
"""
],
tracer: [
type: :any,
doc: """
A tracer, provided to the calculation context.
"""
]
]
@spec run_action!(api :: Ash.Api.t(), input :: Ash.ActionInput.t(), opts :: Keyword.t()) ::
term | no_return
def run_action!(api, input, opts \\ []) do
api
|> run_action(input, opts)
|> unwrap_or_raise!(opts[:stacktraces?])
end
@doc """
Runs a basic action.
Options:
#{Spark.OptionsHelpers.docs(@run_action_opts)}
"""
@spec run_action(api :: Ash.Api.t(), input :: Ash.ActionInput.t(), opts :: Keyword.t()) ::
{:ok, term} | {:error, Ash.Error.t()}
def run_action(api, input, opts \\ []) do
case Spark.OptionsHelpers.validate(opts, @run_action_opts) do
{:ok, opts} ->
Ash.Actions.Action.run(api, input, opts)
{:error, error} ->
{:error, error}
end
end
@doc false
def calculate_opts, do: @calculate_opts
@ -919,6 +973,17 @@ defmodule Ash.Api do
@callback calculate!(resource :: Ash.Resource.t(), calculation :: atom, opts :: Keyword.t()) ::
term | no_return
@doc "Runs a basic action, raising on errors"
@callback run_action!(input :: Ash.ActionInput.t(), opts :: Keyword.t()) ::
term | no_return
@doc "Runs a basic action"
@callback run_action(input :: Ash.ActionInput.t(), opts :: Keyword.t()) ::
{:ok, term} | {:error, term}
@callback calculate!(resource :: Ash.Resource.t(), calculation :: atom, opts :: Keyword.t()) ::
term | no_return
@doc """
Get a record by a primary key. See `c:get/3` for more.
"""

View file

@ -31,6 +31,14 @@ defmodule Ash.Api.Interface do
Api.can(__MODULE__, action_or_query_or_changeset, actor, opts)
end
def run_action!(input, opts \\ []) do
Api.run_action!(__MODULE__, input, opts)
end
def run_action(input, opts \\ []) do
Api.run_action(__MODULE__, input, opts)
end
def calculate!(resource, calculation, opts \\ []) do
case calculate(resource, calculation, opts) do
{:ok, result} ->

View file

@ -325,6 +325,81 @@ defmodule Ash.CodeInterface do
"""
case action.type do
:action ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 2}}
def unquote(interface.name)(
unquote_splicing(arg_vars_function),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
action_input =
opts[:input]
|> Kernel.||(unquote(resource))
|> Ash.ActionInput.for_action(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant, :authorize?, :tracer])
)
unquote(api).run_action(
action_input,
Keyword.drop(opts, [:actor, :changeset, :tenant, :authorize?, :tracer])
)
end
end
@doc doc
@dialyzer {:nowarn_function, {:"#{interface.name}!", Enum.count(args) + 2}}
# sobelow_skip ["DOS.BinToAtom"]
def unquote(:"#{interface.name}!")(
unquote_splicing(arg_vars_function),
params_or_opts \\ %{},
opts \\ []
) do
if opts == [] && Keyword.keyword?(params_or_opts) do
apply(__MODULE__, elem(__ENV__.function, 0), [
unquote_splicing(arg_vars),
%{},
params_or_opts
])
else
input =
unquote(args)
|> Enum.zip([unquote_splicing(arg_vars)])
|> Enum.reduce(params_or_opts, fn {key, value}, params_or_opts ->
Map.put(params_or_opts, key, value)
end)
action_input =
(opts[:input] || unquote(resource))
|> Ash.ActionInput.for_action(
unquote(action.name),
input,
Keyword.take(opts, [:actor, :tenant, :authorize?, :tracer])
)
unquote(api).run_action!(
action_input,
Keyword.drop(opts, [:actor, :changeset, :authorize?, :tracer])
)
end
end
:read ->
@doc doc
@dialyzer {:nowarn_function, {interface.name, Enum.count(args) + 2}}

View file

@ -0,0 +1,29 @@
defmodule Ash.Error.Action.InvalidArgument do
@moduledoc "Used when an invalid value is provided for an action argument"
use Ash.Error.Exception
def_ash_error([:field, :message, :value], class: :invalid)
defimpl Ash.ErrorKind do
def id(_), do: Ash.UUID.generate()
def code(_), do: "invalid_argument"
def message(error) do
"""
Invalid value provided#{for_field(error)}#{do_message(error)}
#{inspect(error.value)}
"""
end
defp for_field(%{field: field}) when not is_nil(field), do: " for #{field}"
defp for_field(_), do: ""
defp do_message(%{message: message}) when not is_nil(message) do
": #{message}."
end
defp do_message(_), do: "."
end
end

View file

@ -4,6 +4,7 @@ defmodule Ash.Policy.Authorizer do
:resource,
:query,
:changeset,
:action_input,
:data,
:action,
:api,
@ -347,7 +348,7 @@ defmodule Ash.Policy.Authorizer do
@impl true
def strict_check_context(_authorizer) do
[:query, :changeset, :api, :resource]
[:query, :changeset, :api, :resource, :action_input]
end
@impl true
@ -366,6 +367,7 @@ defmodule Ash.Policy.Authorizer do
authorizer
| query: context.query,
changeset: context.changeset,
action_input: context[:action_input],
api: context.api
}
|> get_policies()

View file

@ -29,4 +29,6 @@ defmodule Ash.Policy.Check.AccessingFrom do
false
end
end
def match?(_, _, _), do: false
end

View file

@ -20,7 +20,8 @@ defmodule Ash.Policy.Check.ContextEquals do
@impl true
def match?(_, context, opts) do
changeset_or_query = Map.get(context, :changeset) || Map.get(context, :query)
changeset_or_query =
Map.get(context, :changeset) || Map.get(context, :query) || Map.get(context, :action_input)
if is_nil(changeset_or_query) do
false

View file

@ -0,0 +1,60 @@
defmodule Ash.Resource.Actions.Action do
@moduledoc "Represents a custom action on a resource."
defstruct [
:name,
:description,
:returns,
:run,
constraints: [],
touches_resources: [],
arguments: [],
transaction?: false,
primary?: false,
type: :action
]
@type t :: %__MODULE__{
type: :action,
name: atom,
description: String.t() | nil,
arguments: [Ash.Resource.Actions.Argument.t()],
touches_resources: [Ash.Resource.t()],
constraints: Keyword.t(),
run: {module, Keyword.t()},
returns: Ash.Type.t(),
primary?: boolean,
transaction?: boolean
}
import Ash.Resource.Actions.SharedOptions
@global_opts shared_options()
@opt_schema [
returns: [
type: Ash.OptionsHelpers.ash_type(),
doc: "The return type of the action. See `Ash.Type` for more."
],
constraints: [
type: :keyword_list,
doc: """
Constraints for the return type.
For more information see the specific type's documentation,
for general type information see `Ash.Type` and
for practical example [see the constraints topic](/documentation/topics/constraints.md).
"""
],
run: [
type:
{:spark_function_behaviour, Ash.Resource.Actions.Implementation,
{Ash.Resource.Action.ImplementationFunction, 2}}
]
]
|> Spark.OptionsHelpers.merge_schemas(
@global_opts,
"Action Options"
)
@doc false
def opt_schema, do: @opt_schema
end

View file

@ -0,0 +1,21 @@
defmodule Ash.Resource.Actions.Implementation do
@moduledoc """
An implementation of a basic action.
"""
@type context :: %{
optional(:actor) => term,
optional(:tenant) => term,
optional(:authorize?) => boolean,
optional(:api) => module,
optional(any) => any
}
@callback run(Ash.ActionInput.t(), opts :: Keyword.t(), context) ::
{:ok, term()} | {:ok, [Ash.Notifier.Notification.t()]} | {:error, term()}
defmacro __using__(_) do
quote do
@behaviour Ash.Resource.Actions.Implementation
end
end
end

View file

@ -0,0 +1,12 @@
defmodule Ash.Resource.Action.ImplementationFunction do
@moduledoc false
use Ash.Resource.Actions.Implementation
def run(input, [fun: {m, f, a}], context) do
apply(m, f, [input, context | a])
end
def run(input, [fun: fun], context) do
fun.(input, context)
end
end

View file

@ -1,7 +1,7 @@
defmodule Ash.Resource.Actions do
@moduledoc "Types for Ash actions"
alias Ash.Resource.Actions.{Create, Destroy, Read, Update}
alias Ash.Resource.Actions.{Action, Create, Destroy, Read, Update}
@type action :: Create.t() | Read.t() | Update.t() | Destroy.t()
@type action_type :: :read | :create | :update | :destroy
@type action :: Action.t() | Create.t() | Read.t() | Update.t() | Destroy.t()
@type action_type :: :action | :read | :create | :update | :destroy
end

View file

@ -433,6 +433,36 @@ defmodule Ash.Resource.Dsl do
args: [:validation]
}
@action %Spark.Dsl.Entity{
name: :action,
describe: """
Declares a basic action. A combination of arguments, a return type and a run function.
For calling this action, see the `Ash.Api` documentation.
""",
examples: [
"""
action :top_user_emails do
argument :limit, :integer, default: 10, allow_nil?: false
returns {:array, :string}
run fn input, context ->
with {:ok, top_users} <- top_users(input.limit) do
{:ok, Enum.map(top_users, &(&1.email))}
end
end
end
"""
],
target: Ash.Resource.Actions.Action,
schema: Ash.Resource.Actions.Action.opt_schema(),
entities: [
arguments: [
@action_argument
]
],
args: [:name, :returns]
}
@create %Spark.Dsl.Entity{
name: :create,
describe: """
@ -671,6 +701,7 @@ defmodule Ash.Resource.Dsl do
"""
],
entities: [
@action,
@create,
@read,
@update,

View file

@ -21,6 +21,7 @@ defmodule Ash.Resource.Transformers.DefaultAccept do
dsl_state
|> Transformer.get_entities([:actions])
|> Enum.reject(&(&1.type == :action))
|> Enum.reduce({:ok, dsl_state}, fn
%{type: :read}, {:ok, _dsl_state} = acc ->
acc

View file

@ -54,8 +54,7 @@ defmodule Ash.Resource.Transformers.ValidateAccept do
)
)
# read types do not have accept / reject fields
%{type: :read} ->
_ ->
:ok
end)

View file

@ -12,7 +12,7 @@ defmodule Ash.Resource.Transformers.ValidateActionTypesSupported do
def transform(dsl_state) do
dsl_state
|> Transformer.get_entities([:actions])
|> Enum.reject(&(&1.type == :read))
|> Enum.reject(&(&1.type in [:read, :action]))
|> Enum.each(fn action ->
data_layer = Transformer.get_persisted(dsl_state, :data_layer)
resource = Transformer.get_persisted(dsl_state, :module)

View file

@ -15,7 +15,7 @@ defmodule Ash.Resource.Transformers.ValidateManagedRelationshipOpts do
dsl_state
|> Transformer.get_entities([:actions])
|> Enum.reject(&(&1.type == :read))
|> Enum.reject(&(&1.type in [:read, :action]))
|> Enum.each(fn action ->
action.changes
|> Enum.filter(

View file

@ -0,0 +1,90 @@
defmodule Ash.Test.Actions.BasicTest do
@moduledoc false
use ExUnit.Case, async: true
defmodule PassingFredOrGeorge do
use Ash.Policy.SimpleCheck
def describe(_), do: "is one of the twins"
def match?(_, %{action_input: action_input}, _) do
String.downcase(action_input.arguments.name) in ["fred", "george"]
end
end
defmodule Post do
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets, authorizers: [Ash.Policy.Authorizer]
ets do
private?(true)
end
actions do
defaults [:create, :read, :update, :destroy]
action :hello, :string do
argument :name, :string, allow_nil?: false
run(fn input, _context ->
{:ok, "Hello #{input.arguments.name}"}
end)
end
end
attributes do
uuid_primary_key :id
attribute(:title, :string, allow_nil?: false)
timestamps()
end
policies do
policy action(:hello) do
authorize_if PassingFredOrGeorge
end
end
end
defmodule Registry do
@moduledoc false
use Ash.Registry
entries do
entry(Post)
end
end
defmodule Api do
@moduledoc false
use Ash.Api
resources do
registry Registry
end
end
describe "basic actions can be called" do
test "basic actions can be run" do
assert "Hello fred" =
Post
|> Ash.ActionInput.for_action(:hello, %{name: "fred"})
|> Api.run_action!()
end
end
describe "authorization" do
test "basic actions can be authorized" do
assert "Hello fred" =
Post
|> Ash.ActionInput.for_action(:hello, %{name: "fred"})
|> Api.run_action!(authorize?: true)
assert_raise Ash.Error.Forbidden, ~r/Forbidden/, fn ->
Post
|> Ash.ActionInput.for_action(:hello, %{name: "mike"})
|> Api.run_action!(authorize?: true)
end
end
end
end

View file

@ -16,6 +16,7 @@ defmodule Ash.Test.CodeInterfaceTest do
define :read_users, action: :read
define :get_by_id, action: :read, get_by: [:id]
define :create, args: [{:optional, :first_name}]
define :hello, args: [:name]
define_calculation(:full_name, args: [:first_name, :last_name])
@ -39,6 +40,14 @@ defmodule Ash.Test.CodeInterfaceTest do
filter expr(id == ^arg(:id))
end
action :hello, :string do
argument :name, :string, allow_nil?: false
run(fn input, _ ->
{:ok, "Hello #{input.arguments.name}"}
end)
end
end
calculations do
@ -76,6 +85,13 @@ defmodule Ash.Test.CodeInterfaceTest do
end
end
describe "basic actions" do
test "basic actions can be invoked" do
assert "Hello fred" == User.hello!("fred")
assert {:ok, "Hello george"} == User.hello("george")
end
end
describe "calculations" do
test "calculation value can be fetched dynamically" do
assert {:ok, "Zach Daniel"} =