Packages
reactor
0.15.2
1.0.2
1.0.1
1.0.0
0.17.0
0.16.0
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.0
0.10.3
0.10.2
0.10.1
0.10.0
0.9.1
0.9.0
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.0
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
An asynchronous, graph-based execution engine
Current section
Files
Jump to
Current section
Files
lib/reactor/builder.ex
defmodule Reactor.Builder do
@moduledoc """
Build a new Reactor programmatically.
You don't _have_ to use the Reactor DSL to create a Reactor. The functions in
this module allow you to define a Reactor programmatically. This is
especially useful if you need to create a reactor dynamically (maybe based on
a UI such as [React Flow](https://reactflow.dev/)).
## Example
```elixir
reactor = Builder.new()
{:ok, reactor} = Builder.add_input(reactor, :name)
argument = Argument.from_input(:name)
{:ok, reactor} = Builder.add_step(reactor, :greet, [argument])
{:ok, reactor} = Builder.return(reactor, :greet)
```
"""
alias Reactor.{Argument, Builder, Middleware, Step}
import Reactor, only: :macros
import Reactor.Utils
@type step_options :: [
async? | description | guards | max_retries | arguments_transform | context | ref
]
@typedoc "Should the step be run asynchronously?"
@type async? :: {:async?, boolean | (keyword -> boolean)}
@typedoc "An optional step description"
@type description :: {:description, nil | String.t()}
@typedoc "How many times is the step allowed to retry?"
@type max_retries :: {:max_retries, :infinity | non_neg_integer()}
@typedoc "Optionally transform all the arguments into new arguments"
@type arguments_transform ::
{:transform,
nil | (%{optional(atom) => any} -> %{optional(atom) => any}) | {module, keyword} | mfa}
@type ref :: {:ref, :step_name | :make_ref}
@type guards :: {:guards, [Reactor.Guard.Build.t()]}
@typedoc "Optional context which will be merged with the reactor context when calling this step."
@type context :: Reactor.context()
@type step_argument :: Argument.t() | {atom, {:input | :result, any}}
@type impl :: module | {module, keyword}
@doc """
Build a new, empty Reactor.
Optionally an identifier for the Reactor. This is primarily used for recursive
composition tracking.
"""
@spec new(any) :: Reactor.t()
def new(id \\ make_ref()),
do: %Reactor{id: id, context: %{private: %{composed_reactors: MapSet.new([id])}}}
@doc """
Add a named input to the Reactor.
This both places the input in the Reactor for later input validation and adds
steps to the Reactor which will emit and (possibly) transform the input.
"""
@spec add_input(Reactor.t(), any, Builder.Input.options()) :: {:ok, Reactor.t()} | {:error, any}
def add_input(reactor, name, options \\ nil)
def add_input(reactor, _name, _options) when not is_reactor(reactor),
do: {:error, argument_error(:reactor, "not a Reactor", reactor)}
def add_input(reactor, name, options),
do: Builder.Input.add_input(reactor, name, options)
@doc """
Raising version of `add_input/2..3`.
"""
@spec add_input!(Reactor.t(), any, Builder.Input.options()) :: Reactor.t() | no_return
def add_input!(reactor, name, options \\ nil)
def add_input!(reactor, name, options) do
case add_input(reactor, name, options) do
{:ok, reactor} -> reactor
{:error, reason} -> raise reason
end
end
@doc """
Add a step to the Reactor.
Add a new step to the Reactor. Rewrites input arguments to use the result of
the input steps and injects transformation steps as required.
"""
@spec add_step(
Reactor.t(),
name :: any,
impl,
[step_argument],
step_options
) :: {:ok, Reactor.t()} | {:error, any}
def add_step(reactor, name, impl, arguments \\ [], options \\ [])
def add_step(reactor, _name, _impl, _arguments, _options) when not is_reactor(reactor),
do: {:error, argument_error(:reactor, "not a Reactor", reactor)}
def add_step(_reactor, _name, _impl, arguments, _options) when not is_list(arguments),
do: {:error, argument_error(:arguments, "not a list", arguments)}
def add_step(_reactor, _name, _impl, _arguments, options) when not is_list(options),
do: {:error, argument_error(:options, "not a list", options)}
def add_step(reactor, name, impl, arguments, options),
do: Builder.Step.add_step(reactor, name, impl, arguments, options)
@doc """
Raising version of `add_step/3..5`.
"""
@spec add_step!(Reactor.t(), name :: any, impl, [step_argument], step_options) ::
Reactor.t() | no_return
def add_step!(reactor, name, impl, arguments \\ [], options \\ [])
def add_step!(reactor, name, impl, arguments, options) do
case add_step(reactor, name, impl, arguments, options) do
{:ok, reactor} -> reactor
{:error, reason} -> raise reason
end
end
@doc """
Build a step which can be added to a reactor at runtime.
Note that the built step doesn't support transformations - you should add an
additional step to do the transformation needed (this is what `add_step/5`
does anyway).
"""
@spec new_step(any, impl, [step_argument], step_options) ::
{:ok, Step.t()} | {:error, any}
def new_step(name, impl, arguments \\ [], options \\ [])
def new_step(_name, _impl, arguments, _options) when not is_list(arguments),
do: {:error, argument_error(:arguments, "not a list", arguments)}
def new_step(_name, _impl, _arguments, options) when not is_list(options),
do: {:error, argument_error(:options, "not a list", options)}
def new_step(name, impl, arguments, options),
do: Builder.Step.new_step(name, impl, arguments, options)
@doc """
Raising version of `new_step/2..4`.
"""
@spec new_step!(any, impl, [step_argument], step_options) :: Step.t() | no_return
def new_step!(name, impl, arguments \\ [], options \\ [])
def new_step!(name, impl, arguments, options) do
case new_step(name, impl, arguments, options) do
{:ok, step} -> step
{:error, reason} -> raise reason
end
end
@doc """
Specify the return value of the Reactor.
The return value must be the name of a step.
"""
@spec return(Reactor.t(), any) :: {:ok, Reactor.t()} | {:error, any}
def return(reactor, name) do
step_names =
reactor.steps
|> Enum.map(& &1.name)
if name in step_names do
{:ok, %{reactor | return: name}}
else
{:error, argument_error(:name, "not an existing step name.", name)}
end
end
@doc """
Raising version of `return/2`.
"""
@spec return!(Reactor.t(), any) :: Reactor.t() | no_return
def return!(reactor, name) do
case return(reactor, name) do
{:ok, reactor} -> reactor
{:error, reason} -> raise reason
end
end
@doc """
Compose another Reactor inside this one.
Whenever possible this function will extract the steps from inner Reactor and
place them inside the parent Reactor. In order to achieve this the composer
will rename the steps to ensure that there are no conflicts.
If you're attempting to create a recursive Reactor (ie compose a Reactor
within itself) then this will be detected and runtime composition will be used
instead. See `Reactor.Step.Compose` for more details.
"""
@spec compose(Reactor.t(), atom, Reactor.t() | module, [step_argument], Keyword.t()) ::
{:ok, Reactor.t()} | {:error, any}
def compose(reactor, name, inner_reactor, arguments \\ [], options \\ [])
def compose(reactor, _name, _inner_reactor, _arguments, _options) when not is_reactor(reactor),
do: {:error, argument_error(:reactor, "not a Reactor", reactor)}
def compose(_reactor, name, _inner_reactor, _arguments, _options) when not is_atom(name),
do: {:error, argument_error(:name, "not an atom", name)}
def compose(_reactor, _name, inner_reactor, _arguments, _options)
when not is_reactor(inner_reactor) and not is_atom(inner_reactor),
do: {:error, argument_error(:inner_reactor, "not a Reactor", inner_reactor)}
def compose(_reactor, _name, _inner_reactor, arguments, _options) when not is_list(arguments),
do: {:error, argument_error(:arguments, "not a list", arguments)}
def compose(_reactor, _name, _inner_reactor, _arguments, options) when not is_list(options),
do: {:error, argument_error(:options, "not a list", options)}
def compose(reactor, name, inner_reactor, arguments, options),
do: Builder.Compose.compose(reactor, name, inner_reactor, arguments, options)
@doc """
Raising version of `compose/4`.
"""
@spec compose!(Reactor.t(), atom, Reactor.t() | module, [step_argument], Keyword.t()) ::
Reactor.t() | no_return
def compose!(reactor, name, inner_reactor, arguments, options \\ []) do
case compose(reactor, name, inner_reactor, arguments, options) do
{:ok, reactor} -> reactor
{:error, reason} -> raise reason
end
end
@doc """
Add a middleware to the Reactor.
Returns an error if the middleware is already present on the Reactor.
"""
@spec add_middleware(Reactor.t(), Middleware.t()) :: {:ok, Reactor.t()} | {:error, any}
def add_middleware(reactor, middleware) when is_reactor(reactor) and is_atom(middleware) do
with :ok <- assert_unique_middleware(reactor, middleware),
:ok <- assert_is_middleware(middleware) do
{:ok, %{reactor | middleware: [middleware | reactor.middleware]}}
end
end
@doc """
Raising version of `add_middleware/2`.
"""
@spec add_middleware!(Reactor.t(), Middleware.t()) :: Reactor.t() | no_return
def add_middleware!(reactor, middleware) do
case add_middleware(reactor, middleware) do
{:ok, reactor} -> reactor
{:error, reason} -> raise reason
end
end
@doc """
Ensure that a middleware is present on the Reactor.
"""
@spec ensure_middleware(Reactor.t(), Middleware.t()) :: {:ok, Reactor.t()} | {:error, any}
def ensure_middleware(reactor, middleware) when is_reactor(reactor) and is_atom(middleware) do
with false <- middleware in reactor.middleware,
:ok <- assert_is_middleware(middleware) do
{:ok, %{reactor | middleware: [middleware | reactor.middleware]}}
else
true -> {:ok, reactor}
{:error, reason} -> {:error, reason}
end
end
@doc """
Raising version of `ensure_middleware/2`.
"""
@spec ensure_middleware!(Reactor.t(), Middleware.t()) :: Reactor.t() | no_return
def ensure_middleware!(reactor, middleware) do
case ensure_middleware(reactor, middleware) do
{:ok, reactor} -> reactor
{:error, reason} -> raise reason
end
end
defp assert_unique_middleware(reactor, middleware) do
if middleware in reactor.middleware do
argument_error(:middleware, "Middleware is already registered on this Reactor.", middleware)
else
:ok
end
end
defp assert_is_middleware(middleware) do
if Spark.implements_behaviour?(middleware, Middleware) do
assert_middleware_process_callbacks(
middleware,
function_exported?(middleware, :get_process_context, 0),
function_exported?(middleware, :set_process_context, 1)
)
else
{:error,
argument_error(
:middleware,
"Module does not implement the `Middleware` behaviour.",
middleware
)}
end
end
defp assert_middleware_process_callbacks(middleware, true, false) do
{:error,
argument_error(
:middleware,
"When `get_process_context/0` is implemented `set_process_context/1` must also be implemented.",
middleware
)}
end
defp assert_middleware_process_callbacks(middleware, false, true) do
{:error,
argument_error(
:middleware,
"When `set_process_context/1` is implemented `get_process_context/0` must also be implemented.",
middleware
)}
end
defp assert_middleware_process_callbacks(_, _, _), do: :ok
end