Skip to content

Commit

Permalink
feat(Reactor.Dsl.Flunk): Add a special step type which always fails. (#…
Browse files Browse the repository at this point in the history
…125)

This is especially useful for switch branches which should never be reached.
  • Loading branch information
jimsynz authored Sep 15, 2024
1 parent 992d5cd commit 7feb716
Show file tree
Hide file tree
Showing 10 changed files with 466 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ spark_locals_without_parens = [
debug: 2,
default: 0,
default: 1,
flunk: 2,
flunk: 3,
group: 1,
group: 2,
input: 1,
Expand Down
150 changes: 150 additions & 0 deletions documentation/dsls/DSL:-Reactor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/reactor/dsl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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__(),
Expand Down
98 changes: 98 additions & 0 deletions lib/reactor/dsl/flunk.ex
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions lib/reactor/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions lib/reactor/error/invalid/forced_failure_error.ex
Original file line number Diff line number Diff line change
@@ -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
File renamed without changes.
27 changes: 27 additions & 0 deletions lib/reactor/step/fail.ex
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions test/reactor/dsl/flunk_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 7feb716

Please sign in to comment.