diff --git a/.formatter.exs b/.formatter.exs index 549d28d..9a0dfe2 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -20,6 +20,8 @@ spark_locals_without_parens = [ debug: 2, default: 0, default: 1, + flunk: 2, + flunk: 3, group: 1, group: 2, input: 1, diff --git a/documentation/dsls/DSL:-Reactor.md b/documentation/dsls/DSL:-Reactor.md index 444eb49..fac7cd7 100644 --- a/documentation/dsls/DSL:-Reactor.md +++ b/documentation/dsls/DSL:-Reactor.md @@ -23,6 +23,9 @@ The top-level reactor DSL * [debug](#reactor-debug) * argument * wait_for + * [flunk](#reactor-flunk) + * argument + * wait_for * [group](#reactor-group) * argument * wait_for @@ -676,6 +679,153 @@ Target: `Reactor.Dsl.WaitFor` Target: `Reactor.Dsl.Debug` +## reactor.flunk +```elixir +flunk name, message +``` + + +Creates a step which will always cause the Reactor to exit with an error. + +This step will flunk with a `Reactor.Error.Invalid.ForcedFailureError` with it's message set to the provided message. +Additionally, any arguments to the step will be stored in the exception under the `arguments` key. + + +### Nested DSLs + * [argument](#reactor-flunk-argument) + * [wait_for](#reactor-flunk-wait_for) + + +### Examples +``` +flunk :outaroad, "Ran out of road before reaching 88Mph" + +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#reactor-flunk-name){: #reactor-flunk-name .spark-required} | `atom` | | A unique name for the step. Used when choosing the return value of the Reactor and for arguments into other steps. | +| [`message`](#reactor-flunk-message){: #reactor-flunk-message } | `nil \| String.t \| Reactor.Template.Element \| Reactor.Template.Input \| Reactor.Template.Result \| Reactor.Template.Value` | | The message to to attach to the exception. | + + + +## reactor.flunk.argument +```elixir +argument name, source \\ nil +``` + + +Specifies an argument to a Reactor step. + +Each argument is a value which is either the result of another step, or an input value. + +Individual arguments can be transformed with an arbitrary function before +being passed to any steps. + + + + +### Examples +``` +argument :name, input(:name) + +``` + +``` +argument :year, input(:date, [:year]) + +``` + +``` +argument :user, result(:create_user) + +``` + +``` +argument :user_id, result(:create_user) do + transform & &1.id +end + +``` + +``` +argument :user_id, result(:create_user, [:id]) + +``` + +``` +argument :three, value(3) + +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#reactor-flunk-argument-name){: #reactor-flunk-argument-name .spark-required} | `atom` | | The name of the argument which will be used as the key in the `arguments` map passed to the implementation. | +| [`source`](#reactor-flunk-argument-source){: #reactor-flunk-argument-source .spark-required} | `Reactor.Template.Element \| Reactor.Template.Input \| Reactor.Template.Result \| Reactor.Template.Value` | | What to use as the source of the argument. See `Reactor.Dsl.Argument` for more information. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`transform`](#reactor-flunk-argument-transform){: #reactor-flunk-argument-transform } | `(any -> any) \| module \| nil` | | An optional transformation function which can be used to modify the argument before it is passed to the step. | + + + + + +### Introspection + +Target: `Reactor.Dsl.Argument` + +## reactor.flunk.wait_for +```elixir +wait_for names +``` + + +Wait for the named step to complete before allowing this one to start. + +Desugars to `argument :_, result(step_to_wait_for)` + + + + +### Examples +``` +wait_for :create_user +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`names`](#reactor-flunk-wait_for-names){: #reactor-flunk-wait_for-names .spark-required} | `atom \| list(atom)` | | The name of the step to wait for. | + + + + + + +### Introspection + +Target: `Reactor.Dsl.WaitFor` + + + + +### Introspection + +Target: `Reactor.Dsl.Flunk` + ## reactor.group ```elixir group name diff --git a/lib/reactor/dsl.ex b/lib/reactor/dsl.ex index b213f4a..ef3fe9e 100644 --- a/lib/reactor/dsl.ex +++ b/lib/reactor/dsl.ex @@ -28,6 +28,7 @@ defmodule Reactor.Dsl do Dsl.Collect.__entity__(), Dsl.Compose.__entity__(), Dsl.Debug.__entity__(), + Dsl.Flunk.__entity__(), Dsl.Group.__entity__(), Dsl.Input.__entity__(), Dsl.Map.__entity__(), diff --git a/lib/reactor/dsl/flunk.ex b/lib/reactor/dsl/flunk.ex new file mode 100644 index 0000000..183ce60 --- /dev/null +++ b/lib/reactor/dsl/flunk.ex @@ -0,0 +1,98 @@ +defmodule Reactor.Dsl.Flunk do + @moduledoc """ + The struct used to store flunk DSL entities. + + See `d:Reactor.flunk`. + """ + + alias Reactor.{Dsl.Argument, Dsl.Build, Dsl.Flunk, Dsl.WaitFor, Step, Template} + + defstruct __identifier__: nil, + arguments: [], + name: nil, + message: nil + + @type t :: %Flunk{ + __identifier__: any, + arguments: [Argument.t()], + message: Template.t(), + name: atom + } + + @doc false + def __entity__, + do: %Spark.Dsl.Entity{ + name: :flunk, + describe: """ + Creates a step which will always cause the Reactor to exit with an error. + + This step will flunk with a `Reactor.Error.Invalid.ForcedFailureError` with it's message set to the provided message. + Additionally, any arguments to the step will be stored in the exception under the `arguments` key. + """, + examples: [ + """ + flunk :outaroad, "Ran out of road before reaching 88Mph" + """ + ], + args: [:name, :message], + target: __MODULE__, + entities: [arguments: [Argument.__entity__(), WaitFor.__entity__()]], + recursive_as: :steps, + schema: [ + name: [ + type: :atom, + required: true, + doc: """ + A unique name for the step. Used when choosing the return value of the Reactor and for arguments into other steps. + """ + ], + message: [ + type: {:or, [nil, :string, Template.type()]}, + required: false, + default: nil, + doc: """ + The message to to attach to the exception. + """ + ] + ] + } + + defimpl Build do + require Reactor.Template + alias Reactor.{Argument, Builder, Step} + import Reactor.Utils + + def build(flunk, reactor) do + with {:ok, reactor} <- + Builder.add_step( + reactor, + {flunk.name, :arguments}, + Step.ReturnAllArguments, + flunk.arguments, + async?: true, + max_retries: 1, + ref: :step_name + ) do + arguments = + [Argument.from_result(:arguments, {flunk.name, :arguments})] + |> maybe_append(message_argument(flunk)) + + Builder.add_step(reactor, flunk.name, Step.Fail, arguments, + max_retries: 0, + ref: :step_name + ) + end + end + + defp message_argument(flunk) when is_binary(flunk.message), + do: Argument.from_value(:message, flunk.message) + + defp message_argument(flunk) when Template.is_template(flunk.message), + do: Argument.from_template(:message, flunk.message) + + defp message_argument(flunk) when is_nil(flunk.message), do: nil + + def verify(_, _), do: :ok + def transform(_, dsl_state), do: {:ok, dsl_state} + end +end diff --git a/lib/reactor/error.ex b/lib/reactor/error.ex index d52cf8d..a492b39 100644 --- a/lib/reactor/error.ex +++ b/lib/reactor/error.ex @@ -21,4 +21,41 @@ defmodule Reactor.Error do import Reactor.Utils end end + + @doc """ + Recursively searches through nested reactor errors for the first instance of + the provided module. + """ + @spec fetch_error(Exception.t(), module) :: {:ok, Exception.t()} | :error + def fetch_error(error, module) when is_exception(error, module), do: {:ok, error} + + def fetch_error(error, module) when is_list(error.errors) do + Enum.reduce_while(error.errors, :error, fn error, :error -> + case fetch_error(error, module) do + {:ok, error} -> {:halt, {:ok, error}} + :error -> {:cont, :error} + end + end) + end + + def fetch_error(error, module) when is_exception(error.error), + do: fetch_error(error.error, module) + + def fetch_error(_error, _module), do: :error + + @doc """ + Recursively searches through nested reactor errors and returns any errors + which match the provided module name. + """ + @spec find_errors(Exception.t(), module) :: [Exception.t()] + def find_errors(error, module) when is_exception(error, module), do: [error] + + def find_errors(error, module) when is_list(error.errors) do + Enum.flat_map(error.errors, &find_errors(&1, module)) + end + + def find_errors(error, module) when is_exception(error.error), + do: find_errors(error.error, module) + + def find_errors(_error, _module), do: [] end diff --git a/lib/reactor/error/invalid/forced_failure_error.ex b/lib/reactor/error/invalid/forced_failure_error.ex new file mode 100644 index 0000000..c7437c3 --- /dev/null +++ b/lib/reactor/error/invalid/forced_failure_error.ex @@ -0,0 +1,23 @@ +defmodule Reactor.Error.Invalid.ForcedFailureError do + @moduledoc """ + This error is returned when the `flunk` DSL entity or the `Reactor.Step.Fail` + step are called. + """ + + use Reactor.Error, + fields: [:arguments, :step_name, :message, :context, :options], + class: :invalid + + @type t :: %__MODULE__{ + __exception__: true, + arguments: %{atom => any}, + step_name: any, + message: String.t(), + context: map, + options: keyword + } + + @doc false + @impl true + def message(error), do: error.message +end diff --git a/lib/reactor/error/unknown/unknown.ex b/lib/reactor/error/unknown/unknown_error.ex similarity index 100% rename from lib/reactor/error/unknown/unknown.ex rename to lib/reactor/error/unknown/unknown_error.ex diff --git a/lib/reactor/step/fail.ex b/lib/reactor/step/fail.ex new file mode 100644 index 0000000..70451d1 --- /dev/null +++ b/lib/reactor/step/fail.ex @@ -0,0 +1,27 @@ +defmodule Reactor.Step.Fail do + @moduledoc """ + A very simple step which immediately returns an error. + """ + + use Reactor.Step + alias Reactor.Error.Invalid.ForcedFailureError + + @doc false + @impl true + @spec run(Reactor.inputs(), Reactor.context(), keyword) :: {:error, ForcedFailureError.t()} + def run(arguments, context, options) do + {:error, + ForcedFailureError.exception( + arguments: arguments.arguments, + message: arguments.message, + context: context, + options: options, + step_name: context.current_step.name + )} + end + + @doc false + @impl true + @spec can?(Reactor.Step.t(), Reactor.Step.capability()) :: boolean + def can?(_step, _capability), do: false +end diff --git a/test/reactor/dsl/flunk_test.exs b/test/reactor/dsl/flunk_test.exs new file mode 100644 index 0000000..3283cbe --- /dev/null +++ b/test/reactor/dsl/flunk_test.exs @@ -0,0 +1,23 @@ +defmodule Reactor.Dsl.FailTest do + use ExUnit.Case, async: true + + alias Reactor.{Error, Error.Invalid.ForcedFailureError} + + defmodule FailReactor do + @moduledoc false + use Reactor + + flunk(:flunk, "Fail") + end + + test "it returns an forced failure error" do + assert {:error, error} = Reactor.run(FailReactor, []) + + assert is_exception(error) + + assert {:ok, error} = Error.fetch_error(error, ForcedFailureError) + assert error.message == "Fail" + assert error.arguments == %{} + assert error.step_name == :flunk + end +end diff --git a/test/reactor/error_test.exs b/test/reactor/error_test.exs new file mode 100644 index 0000000..4d149d9 --- /dev/null +++ b/test/reactor/error_test.exs @@ -0,0 +1,105 @@ +defmodule Reactor.ErrorTest do + @moduledoc false + use ExUnit.Case, async: true + + alias Reactor.{Error, Error.Invalid.UndoStepError, Error.Unknown, Error.Unknown.UnknownError} + + describe "fetch_error/2" do + test "when the error is of the provided type, it returns it" do + error = UnknownError.exception(error: "🤔") + + assert {:ok, ^error} = Error.fetch_error(error, UnknownError) + end + + test "when the error is not of the provided type, it returns an error atom" do + error = Unknown.exception(errors: []) + + assert :error = Error.fetch_error(error, UnknownError) + end + + test "when the error directly contains the provided type, it returns it" do + nested = UndoStepError.exception() + error = UnknownError.exception(error: nested) + + assert {:ok, ^nested} = Error.fetch_error(error, UndoStepError) + end + + test "when the error collects the provided type, it returns the first match" do + nested0 = UnknownError.exception(error: "0") + nested1 = UnknownError.exception(error: "1") + error = Unknown.exception(errors: [nested0, nested1]) + + assert {:ok, ^nested0} = Error.fetch_error(error, UnknownError) + end + + test "it searches recursively" do + root = UndoStepError.exception() + + error = + Unknown.exception( + errors: [ + UnknownError.exception( + error: + Unknown.exception( + errors: [ + UnknownError.exception(error: root) + ] + ) + ) + ] + ) + + assert {:ok, ^root} = Error.fetch_error(error, UndoStepError) + end + end + + describe "find_errors/2" do + test "when the error is the provided type it returns it" do + error = UnknownError.exception(error: "🤔") + assert [^error] = Error.find_errors(error, UnknownError) + end + + test "when the error directly contains the provided type it returns it" do + nested = UndoStepError.exception() + error = UnknownError.exception(error: nested) + + assert [^nested] = Error.find_errors(error, UndoStepError) + end + + test "when the error collects the provided type, it returns all instances of it" do + nested0 = UnknownError.exception(error: "0") + nested1 = UnknownError.exception(error: "1") + error = Unknown.exception(errors: [nested0, nested1]) + + assert [^nested0, ^nested1] = Error.find_errors(error, UnknownError) + end + + test "it searches recursively" do + nested0 = UndoStepError.exception(step: :zero) + nested1 = UndoStepError.exception(step: :one) + nested2 = UndoStepError.exception(step: :two) + + error = + Unknown.exception( + errors: [ + UnknownError.exception( + error: + Unknown.exception( + errors: [ + Unknown.exception( + errors: [ + UnknownError.exception(error: nested2) + ] + ), + nested1 + ] + ) + ), + nested0 + ] + ) + + assert [^nested2, ^nested1, ^nested0] = Error.find_errors(error, UndoStepError) + end + end +end