Macro
- Last updated 10 Aug 23
Overview
Macros provide shorthand methods to create and configure a particular step in your operation. Trailblazer ships with a handful of macros. Those are implemented in trailblazer-macro and trailblazer-macro-contract.
Writing you own macros is a very simple thing to do if you follow the macro API. API docs
General notes
Most macros internally use variable mapping to allow injection of configuration variables or to limit their own scope. Macros do so by leveraging the composable API using In()
, Inject()
and Out()
.
This allows you to add your own variable mapping to a macro, as illustrated in the example below.
module Song::Activity
class Create < Trailblazer::Operation
step Model(Song, :find_by),
In() => ->(ctx, my_id:, **) { ctx.merge(params: {id: my_id}) } # Model() needs {params[:id]}.
# ...
end
end
When running the above operation, the exemplary Model()
macro will pick the ID from :my_id
.
result = Create.(my_id: 1)
Please note that you shall not use :input
to configure your macro as it will override internal filters and breaks the macro’s configuration. Go with the new composable variable mapping which is available from trailblazer
2.1.1 and onwards.
Nested
Only use the Nested()
macro if you’re planning to nest an activity that can only be chosen at runtime.
- If you know the nested activity upfront, use Subprocess().
- If you know the activities upfront, and need only need to choose at run-time, use the
:auto_wire
option.
Dynamic
Use Nested()
to dynamically decide which nested activity to invoke, but when you don’t know all activities to pick from. This is sometimes the case in polymorphic setups, when a part of an operation needs to be “decided” at runtime, with no way to know what this will look like when writing the operation class.
Consider the following activity to create a song. Depending on the input in params
, you either want to run a nested Id3Tag
processor activity for an MP3 song, or VorbisComment
at a specific point during the song creation.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :model
step Nested(:decide_file_type) # Run either {Id3Tag} or {VorbisComment}
step :save
# ...
def decide_file_type(ctx, params:, **)
params[:type] == "mp3" ? Id3Tag : VorbisComment
end
end
end
When using Nested()
with only one argument, the decider, we call this the “dynamic style”. Since we don’t know the possible nested activities upfront, the macro needs to compile several internals at runtime, which will be slower than its static equivalent.
The decider can be any “option style” callable: an instance method in the hosting Create
activity, any lambda or proc, or a callable object or class. In our example, it’s an instance method.
The decider receives ctx
and keyword arguments just like any other step. It is run directly before the nested activity is run, it’s return value decides about which one that will be: Id3Tag
or VorbisComment
.
The nested activity (or operation) will, per default, receive the same ctx
that a step
in place of Nested()
would receive.
module Song::Activity
class Id3Tag < Trailblazer::Activity::Railway
step :parse
step :encode_id3
# ...
end
end
Auto_wire
The recommended way of maintaining dynamically nested operations (or activities) is to use Nested()
with the :auto_wire
option. It works exactly like its purely dynamic counterpart but requires you to specify all possible nested activities at compile-time.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :model
step Nested(:decide_file_type,
auto_wire: [Id3Tag, VorbisComment]) # explicitely define possible nested activities.
step :save
# ...
def decide_file_type(ctx, params:, **)
params[:type] == "mp3" ? Id3Tag : VorbisComment
end
end
end
The :auto_wire
option expects an array of nested activities. While this might seem a bit clumsy, this brings several benefits. First of all, execution is much faster since the entire activity graph is created at compile-time. Second, you can use the Debugging API to introspect things.
You can use any type of decider step. In this example, it’s a instance method #decide_file_type
on the Nested()
-hosting class.
Internally, the “auto-wire” Nested()
macro simply returns a small activity as illustrated above in gray. It has a decide
step to figure out which nested activity to run, then runs the actual nested activity. All well-known termini (success, failure, pass_fast, fail_fast) of the nested activities are automatically connected to the Nested()
’s activity’s termini.
Given we had a nested activity VorbisComment
implemented like so.
module Song::Activity
class VorbisComment < Trailblazer::Activity::Railway
step :prepare_metadata
step :encode_cover, Output(:failure) => End(:unsupported_file_format)
# ...
end
end
If the #encode_cover
step fails, the activity will stop in the End.unsupported_file_format
terminus - an end event very specific to the VorbisComment
activity.
This new terminus has to be wired explicitely in the nesting activity.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step :model
step Nested(
:decide_file_type,
auto_wire: [Id3Tag, VorbisComment]
),
# Output and friends are used *after* Nested().
# Connect VorbisComment's {unsupported_file_format} to our {failure} track:
Output(:unsupported_file_format) => Track(:failure)
step :save
# ...
end
end
Using Output(:unsupported_file_type)
from the Wiring API you can connect this specific terminus to wherever you need it, here it’s routed to the failure track in Create
. Note that Wiring API options are used after Nested(...)
, not within its parenthesis.
The complete activity graph would look a bit like the following diagram.
Compliance
Nested
setups are traceable using #wtf?
.
Trailblazer::Developer.wtf?(Song::Activity::Create, params: {type: "vorbis"})
The above examples result in a flow as follows.
The trace is fully compatible with the Debugging API and allows compile-time introspection. For example, you can specify the path to a nested activity Id3Tag
and inspect it.
Trailblazer::Developer.render(Song::Activity::Create,
path: [
"Nested/decide_file_type", # ID of Nested()
Song::Activity::Id3Tag # ID of the nested {Id3Tag} activity.
]
)
Note that the ID of the nested activities are the class constants.
Wrap
Sometimes you need to run a sequence of steps within some block you provide. Often, this is required when certain steps must be wrapped in a database transaction. The Wrap()
macro does just that.
module Song::Activity
class Upload < Trailblazer::Activity::FastTrack
step :model
step Wrap(MyTransaction) {
step :update # this changes the database.
step :transfer # this might even break!
}
step :notify
fail :log_error
# ...
end
end
In an imaginary song Upload
operation that transfers a music file to some streaming service platform while updating fields on the database, needs the steps #update
and #transfer
being run inside a transaction. The code running and interpreting this block is provided via the custom transaction MyTransaction
.
Wrap handler
The most simple “transaction” (we call it wrap handler) could look as follows.
class MyTransaction
def self.call((ctx, flow_options), **, &block)
signal, (ctx, flow_options) = yield # calls the wrapped steps
return signal, [ctx, flow_options]
end
end
Your transaction handler can be any type of [callable] exposing the circuit interface.
In the most simple transaction ever written, we simply run the wrapped steps by calling yield
. This will return a circuit-interface return set. The interesting parts are the returned ctx
, which is the ctx that left the wrapped steps, and the signal
, which indicates the outcome of running the wrapped steps.
Here, we simply return the original signal of the wrapped activity.
Note that internally #update
and #transfer
are put into a separate activity. The terminus reached in that activity is the signal.
Given that both #update
and #transfer
are railway steps, the wrapped code can terminate in a success
terminus, or a failure
.
Now, it’s your handler that is responsible to interpret that. In the example above, we simply pass on the wrapped activity’s terminal signal, making the Upload
activity either continue from success
or from failure
.
Let’s assume the #transfer
step had a custom output that goes to a super-special terminus End.timeout
.
step Wrap(MyTransaction) {
step :update # this changes the database.
step :transfer,
Output(:failure) => End(:timeout) # creates a third terminus.
},
The resulting wrapped activity now has three termini.
With our handler, which simply returns the wrapped activity’s signal, it’s possible to route the :timeout
signal in Upload
. For instance, if a :timeout
should be resulting in Upload
jumping to the fail_fast
track after #transfer
terminated in timeout
, you can do so.
step Wrap(MyTransaction) {
step :update # this changes the database.
step :transfer,
Output(:failure) => End(:timeout) # creates a third terminus.
},
Output(:timeout) => Track(:fail_fast) # any wiring is possible here.
Here’s what the circuit would look like.
Transaction
Wrapping steps into a real database transaction, and rolling back in case of a failure outcome in one of the steps, could look like so.
class MyTransaction
def self.call((ctx, flow_options), **)
handler_signal = nil # this is the signal we decide to return from Wrap().
ActiveRecord::Base.transaction do
signal, (ctx, flow_options) = yield # call the Wrap block
# It's now up to us to interpret the wrapped activity's outcome.
terminus_semantic = signal.to_h[:semantic]
if [:success, :pass_fast].include?(terminus_semantic)
handler_signal = Trailblazer::Activity::Right
else # something in the wrapped steps went wrong:
handler_signal = Trailblazer::Activity::Left
raise ActiveRecord::Rollback # This is the only way to tell ActiveRecord to rollback!
end
end # transaction
return handler_signal, [ctx, flow_options]
end
end
This might look a bit clumsy, but most of the code is very straight-forward.
- Your main intent, wrapping a sequence of steps into an
ActiveRecord::Base.transaction
block, contains theyield
invocation that runs the wrapped steps. - Having the
signal
of the wrapped activity returned, you can decide how you’d like to interpret that. Usually, looking at the signals:semantic
field indicates the outcome. - In case you’re not happy, a
raise ActiveRecord::Rollback
will make ActiveRecord undo whatever database commits happened in the block. - Instead of returning the nested activity’s
signal
, which is completely legit,Wrap()
also allows you to return aRight
orLeft
signal to communicate the outcome of the entireWrap()
component.
Exception handling
Catching and intercepting exceptions in Wrap()
works identical to transactions. In case you don’t want to use our canonical Rescue() macro here is a sample wrap handler that uses rescue
for intercepting exceptions thrown in #update
or #transfer
.
class MyRescue
def self.call((ctx, flow_options), **, &block)
signal, (ctx, flow_options) = yield # calls the wrapped steps
return signal, [ctx, flow_options]
rescue
ctx[:exception] = $!.message
return Trailblazer::Activity::Left, [ctx, flow_options]
end
end
With any exception being thrown, the original signal from the wrapped activity is returned, as if the steps were part of the Upload
operation flow.
The code below rescue
is only run when an exception was thrown. Here, we write a new variable :exception
to the ctx
and return Left
, indicating an error.
Compliance
You can use tracing with Wrap()
.
Trailblazer::Developer.wtf?(Song::Activity::Upload, params: {id: 1})
The trace per default shows Wrap’s ID.
You can use any Introspect
mechanics on activities using Wrap()
.
node, _ = Trailblazer::Developer::Introspect.find_path(
Song::Activity::Upload,
["Wrap/MyTransaction", :transfer])
#=> #<Node ...>
Wrap()
is compatible with the Patch API, as in, you may replace nested steps or entire activities within the wrapped part.
Consider the Upload
activity used throughout this example.
module Song::Activity
class Upload < Trailblazer::Activity::FastTrack
step :model
step Wrap(MyTransaction) {
step :update # this changes the database.
step :transfer # this might even break!
}
step :notify
fail :log_error
# ...
end
end
Say you want to replace the #update
step within the wrap with a new step #upsert
, you can use Patch
as so.
upload_with_upsert = Trailblazer::Activity::DSL::Linear::Patch.call(
Song::Activity::Upload,
["Wrap/MyTransaction"],
-> { step :upsert, replace: :update }
)
The returned new activity upload_with_upsert
is a copy of Upload
with the respective step replace. Note that in this very example, the method #upsert
has to be included in the nested activity.
Each
The Each()
macro allows to loop over a dataset while invoking a particular operation per iteration, as if multiple identical operations were serially connected, but receiving different input ctxs.
module Song::Activity
class Cover < Trailblazer::Activity::Railway
step :model
step Each(dataset_from: :composers_for_each) {
step :notify_composers
}
step :rearrange
# "decider interface"
def composers_for_each(ctx, model:, **)
model.composers
end
def notify_composers(ctx, index:, item:, **)
Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
end
# ...
end
end
module Song::Operation
class Cover < Trailblazer::Operation
step :model
step Each(dataset_from: :composers_for_each) {
step :notify_composers
}
step :rearrange
# "decider interface"
def composers_for_each(ctx, model:, **)
model.composers
end
def notify_composers(ctx, index:, item:, **)
Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
end
# ...
end
end
You can either pass a block with steps to iterate, or an entire operation/activity. In this example, a block is used. Note that you need to use the {}
curly braces due to Ruby’s precedence, just as it’s done in Wrap()
.
dataset_from
While you could simply assign a ctx variable :dataset
in a step preceding the Each()
macro, the recommended way is using :dataset_from
and implementing a dedicated dataset provider.
step Each(dataset_from: :composers_for_each) {
step :notify_composers
}
This can be an instance method or any kind of callable, following the “decider interface”.
def composers_for_each(ctx, model:, **)
model.composers
end
Note that our dataset provider is an instance method #composers_for_each
defined on the operation class hosting Each()
. It exposes a step signature with ctx and handy keyword arguments and simply returns the dataset. It explicitely does not write to ctx
!
The only requirement to the dataset provider’s return value is that it returns an enumerable object - usually, that’s an array.
Iterated Block
Note that the iterated block’s :instance_method
s are defined in the Each()
-hosting activity, too.
def notify_composers(ctx, index:, item:, **)
Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
end
Per default, Each()
will loop over the dataset using #each
and pass the iterated item and its index into each step of the block, named :item
and :index
.
If you don’t like the :item
keyword argument in your steps, you can configure it using the :item_key
option for Each()
.
step Each(dataset_from: :composers_for_each, item_key: :composer) {
step :notify_composers
}
The iterated steps now receive a :composer
ctx variable instead of :item
.
module Song::Activity
class Cover < Trailblazer::Activity::Railway
# ...
def notify_composers(ctx, index:, composer:, **)
Mailer.send(to: composer.email, message: "#{index}) You, #{composer.full_name}, have been warned about your song being copied.")
end
end
end
module Song::Operation
class Cover < Trailblazer::Operation
# ...
def notify_composers(ctx, index:, composer:, **)
Mailer.send(to: composer.email, message: "#{index}) You, #{composer.full_name}, have been warned about your song being copied.")
end
end
end
If you would like the iterated steps to be within the “block”, use a dedicted activity or operation.
module Song::Activity
class Cover < Trailblazer::Activity::Railway
step :model
step Each(Notify, dataset_from: :composers_for_each)
step :rearrange
# ...
end
end
module Song::Operation
class Cover < Trailblazer::Operation
step :model
step Each(Notify, dataset_from: :composers_for_each)
step :rearrange
# ...
end
end
The iterated operation is composed of steps that have an identical interface with the block version.
module Song::Activity
class Notify < Trailblazer::Activity::Railway
step :send_email
def send_email(ctx, index:, item:, **)
Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
end
end
end
module Song::Operation
class Notify < Trailblazer::Operation
step :send_email
def send_email(ctx, index:, item:, **)
Mailer.send(to: item.email, message: "#{index}) You, #{item.full_name}, have been warned about your song being copied.")
end
end
end
Collect option
Ctx variables set within an iteration are discarded per default. While you could configure collecting values yourself, you can use the :collect
option.
step Each(dataset_from: :composers_for_each, collect: true) {
step :notify_composers
}
If one of your iterated steps sets ctx[:value]
within Each()
, this value will be collected.
def notify_composers(ctx, index:, item:, **)
ctx[:value] = [index, item.full_name]
end
All collected values are available at ctx[:collected_from_each]
when Each()
is finished.
ctx = {params: {id: 1}} # Song 1 has two composers.
signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Cover, ctx)
puts ctx[:collected_from_each] #=> [[0, "Fat Mike"], [1, "El Hefe"]]
ctx = {params: {id: 1}} # Song 1 has two composers.
result = Song::Operation::Cover.(ctx)
puts result[:collected_from_each] #=> [[0, "Fat Mike"], [1, "El Hefe"]]
Variable mapping
The Each()
macro allows to configure what goes in and out of each iteration. However, it provides a default setting.
step Each(dataset_from: :composers_for_each) {
step :notify_composers
step :write_to_ctx
}
- Per default, the iterated steps can see the entire outer
ctx
, plus:index
and:item
. - Any variable written to
ctx
is discarded after the iteration.
def write_to_ctx(ctx, index:, seq:, **)
# ...
ctx[:variable] = index # this is discarded!
end
This default setting assures that no data from the iteration bleeds into the outer ctx.
You may configure what goes in and out of the iterated activity. Use the variable mapping API by passing arguments to Each()
.
For example, say you want to collect messages from each iteration.
module Song::Activity
class Cover < Trailblazer::Activity::Railway
step :model
step Each(dataset_from: :composers_for_each,
Inject(:messages) => ->(*) { {} },
# all filters called before/after each iteration!
Out() => [:messages]
) {
step :write_to_ctx
}
step :rearrange
def write_to_ctx(ctx, item:, messages:, index:, **)
ctx[:messages] = messages.merge(index => item.full_name)
end
# ...
end
end
module Song::Operation
class Cover < Trailblazer::Operation
step :model
step Each(dataset_from: :composers_for_each,
Inject(:messages) => ->(*) { {} },
# all filters called before/after each iteration!
Out() => [:messages]
) {
step :write_to_ctx
}
step :rearrange
def write_to_ctx(ctx, item:, messages:, index:, **)
ctx[:messages] = messages.merge(index => item.full_name)
end
# ...
end
end
- When starting the iteration, the ctx variable
:messages
will be initialized to an empty hash (unless you provide it in the outside ctx). - The
#write_to_ctx
step withinEach()
sees that:messages
variable and can override it, adding its non-sense message to the hash. - Since you configured
Out() => [:messages]
, the following iteration will see that very:messages
variable from the last iteration.
This allows to collect outgoing variables from the iterations, even in case of failures.
Note how the Inject()
and Out()
calls are within the parenthesis of Each()
.
Failure
If not configured, a failing iterated step leads the entire iteration to be stopped. The Each()
activity will terminate on its failure
terminus.
You can still access ctx[:collected_from_each]
for each iterated block. Note that you can even see which iteration failed in the trace!
In combination with TRB’s debugger, this gives you a powerful tool for finding bugs in your code or understanding the flow, without having to jump around through iterations using pry
or other tools.
Compliance
You can use tracing with Each()
.
Trailblazer::Developer.wtf?(Song::Activity::Cover, [{
params: {id: 1},
# ...
}])
Note that a virtual step invoke_block_activity
is displayed to group the iterations, suffixed with the number of the iteration.
TODO: show how runtime data can be accessed for each iterated block.
You can use any Introspect
mechanics on the nested activity using Each()
.
node, _ = Trailblazer::Developer::Introspect.find_path(
Song::Activity::Cover,
["Each/composers_for_each", "Each.iterate.block", "invoke_block_activity", :notify_composers])
#=> #<Node ...>
You may patch the iterated activity.
cover_patched = Trailblazer::Activity::DSL::Linear::Patch.(
Song::Activity::Cover,
["Each/composers_for_each", "Each.iterate.block"],
-> { step :log_email }
)
Here, the Each.iterate.block
task represents the nested iterated activity.
Model
An operation can automatically find or create a model for you using the Model()
macro. The produced model is written to ctx[:model]
.
You could easily write this code yourself, nevertheless, our macro comes with a bunch of helpful features.
Model::Find
In operations that target existing models, the Model::Find()
macro is the right tool to use.
To find a model by its ID using Song.find_by(id: id)
use the macro like so.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model::Find(Song, find_by: :id)
step :validate
step :save
# ...
end
end
The id
value is extracted from id = params[:id]
.
Note that the finder method doesn’t have to be find_by
- you may pass any method name to Find()
, for example :where
.
The id:
key is also up to you. As an example, you can dictate a query with a different key using the following code.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model::Find(Song, find_by: :short_id) # Song.find_by(short_id: params[:short_id])
step :validate
step :save
# ...
end
end
Sometimes the ID key and the params
key differ. Use the :params_key
option if you want to look into params
using a different key.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model::Find(Song, find_by: :id, params_key: :slug) # Song.find_by(id: params[:slug])
step :validate
step :save
# ...
end
end
If the ID extraction is more complicated, maybe you need to look into a deeply nested hash in params
, use the block style to implement your own extraction.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model::Find(Song, find_by: :id) { |ctx, params:, **|
params[:song] && params[:song][:id]
}
step :validate
step :save
# ...
end
end
The block receives the same ctx, **kws
style arguments that an ordinary step would.
You can wire the Model::Find()
step to a dedicated terminus :not_found
in case of a missing model (instead of connecting it to the default failure track). The new terminus represents an explicit outcome and could, for instance, be used in an endpoint to render a 404 response without having to check if ctx[:model]
is present or not.
To add the explicit End.not_found
terminus, pass the :not_found_terminus
option to the macro.
class Song
module Activity
class Update < Trailblazer::Activity::Railway
step Model::Find(Song, find_by: :id, not_found_terminus: true)
step :validate
step :save
include T.def_steps(:validate, :save)
end
end
end
When running the Update
activity with an invalid ID, it terminates on End.not_found
.
signal, (ctx, _) = Trailblazer::Activity::TaskWrap.invoke(Song::Activity::Update, [{params: {id: nil}},{}])
puts signal #=> #<Trailblazer::Activity::End semantic=:not_found>
In case your model needs to be retrieved using a positional ID argument, use the :find
style without a hash.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model::Find(Song, :find) # Song.find(id)
step :validate
step :save
# ...
end
end
This will result in an invocation like Song.find(id)
. As always :find
can be anything, for example, :[]
in case you’re using Sequel.
With tracing turned on, you can see that Model::Find()
actually creates a tiny activity with two steps.
- The first extracts the ID from params. If this step fails, your configured ID couldn’t be found
- The second step runs the finder.
Model::Build
For Create
operations without an existing model you can use Model::Build
to instantiate a new model.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step Model::Build(Song, :new)
step :validate
step :save
# ...
end
end
Here, :new
might be replace with any method you want to be called on Song
, e.g. :build
.
Dependency Injection
Model
The Model()
macro will be deprecated and removed in Trailblazer 2.2. Please switch over to the more powerful Model::Find
and friends.
An operation can automatically find or create a model for you using the Model()
macro.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step Model(Song, :new)
step :validate
step :save
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step Model(Song, :new)
step :validate
step :save
# ...
end
end
After this step, there is a fresh model instance under ctx[:model]
that can be used in all following steps and the returned result object.
signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Create, params: {})
puts ctx[:model] #=> #<struct Song id=nil, title=nil>
result = Song::Operation::Create.(params: {})
puts result[:model] #=> #<struct Song id=nil, title=nil>
Internally, the Model()
macro will simply invoke Song.new
to populate ctx[:model]
.
You can also find models using :find_by
. This is helpful for Update
or Delete
operations.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model(Song, :find_by)
step :validate
step :save
# ...
end
end
module Song::Operation
class Update < Trailblazer::Operation
step Model(Song, :find_by)
step :validate
step :save
# ...
end
end
The Model
macro will invoke the following code for you.
ctx[:model] = Song.find_by(id: params[:id])
This will assign ctx[:model]
for you by invoking find_by
.
signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Update, params: {id: 1}, seq: [])
ctx[:model] #=> #<Song id=1, ...>
puts signal #=> #<Trailblazer::Activity::End semantic=:success>
result = Song::Operation::Update.(params: {id: 1}, seq: [])
result[:model] #=> #<Song id=1, ...>
result.success? # => true
If Song.find_by
returns nil
, this will deviate to the failure track, skipping the rest of the operation.
signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Update, params: {})
ctx[:model] #=> nil
puts signal #=> #<Trailblazer::Activity::End semantic=:failure>
result = Song::Operation::Update.(params: {})
result[:model] #=> nil
result.success? # => false
It is also possible to find_by
using an attribute other than :id
.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model(Song, :find_by, :title) # third positional argument.
step :validate
step :save
# ...
end
end
module Song::Operation
class Update < Trailblazer::Operation
step Model(Song, :find_by, :title) # third positional argument.
step :validate
step :save
# ...
end
end
Note that, instead of find_by
, you may also use :find
. This is not recommended, though, since it raises an exception, which is not the preferred way of flow control in Trailblazer.
It’s possible to specify any finder method, which is helpful with ROMs such as Sequel.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model(Song, :[])
step :validate
step :save
# ...
end
end
module Song::Operation
class Update < Trailblazer::Operation
step Model(Song, :[])
step :validate
step :save
# ...
end
end
The provided method will be invoked and Trailblazer passes to it the value ofparams[:id]
.
Song[params[:id]]
Given your database gem provides that finder, it will result in a query.
signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Update, params: {id: 1}, seq: [])
ctx[:model] #=> #<struct Song id=1, title="Roxanne">
result = Song::Operation::Update.(params: {id: 1}, seq: [])
result[:model] #=> #<struct Song id=1, title="Roxanne">
You can wire the Model()
step to a dedicated terminus :not_found
in case of a missing model, instead of connecting it to the default failure track. The new terminus represents an explicit outcome and could, for instance, be used in an endpoint to render a 404 response without having to check if ctx[:model]
is present or not.
To add the explicit End.not_found
terminus, pass the :not_found_terminus
option to the macro.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model(Song, :find_by, not_found_terminus: true)
step :validate
step :save
# ...
end
end
module Song::Operation
class Update < Trailblazer::Operation
step Model(Song, :find_by, not_found_terminus: true)
step :validate
step :save
# ...
end
end
When running the Update
activity with an invalid ID, it terminates on End.not_found
.
signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Update, params: {id: nil})
puts signal #=> #<Trailblazer::Activity::End semantic=:not_found>
result = Song::Operation::Update.(params: {id: nil})
result.success? # => false
The following Model()
options can be injected using variables when call
ing the operation.
:"model.class"
which represents the first argument forModel()
.:"model.action"
representing the second argument.:"model.find_by_key"
which represents the third argument.
As per your design, you may inject one or more of those variables as follows.
signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Create,
params: {title: "Olympia"}, # some random variable.
"model.class": Hit,
"model.action": :find_by,
"model.find_by_key": :title, seq: []
)
result = Song::Operation::Create.(
params: {title: "Olympia"}, # some random variable.
"model.class": Hit,
"model.action": :find_by,
"model.find_by_key": :title, seq: []
)
You can even leave Model()
entirely unconfigured.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step Model()
step :validate
step :save
# ...
end
module Song::Operation
class Create < Trailblazer::Operation
step Model()
step :validate
step :save
# ...
end
This implies you inject all required variables at run-time.
Usually, the specific model class for Model()
is set directly in the macro.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step Model(Song, :new)
step :validate
step :save
# ...
end
end
module Song::Operation
class Create < Trailblazer::Operation
step Model(Song, :new)
step :validate
step :save
# ...
end
end
Nevertheless, it’s possible to override it using the :"model.class"
variable when invoking the operation/activity.
signal, (ctx, _) = Trailblazer::Activity.(Song::Activity::Create, params: {}, :"model.class" => Hit)
result = Song::Operation::Create.(params: {}, :"model.class" => Hit)
Note that you don’t have to configure any model class at all when injecting it.
Rescue
While you could be implementing your own begin/rescue/end
mechanics using Wrap
, Trailblazer offers you the Rescue()
macro to catch and handle exceptions that might occur while running any series of steps.
class Song::Activity::Create < Trailblazer::Activity::Railway
step :create_model
step Rescue() {
step :upload
step :rehash
}
step :notify
fail :log_error
# ...
end
Any exception raised during a step in the Rescue
block will cause the execution to stop, and continues after the block on the left (or failure
) track.
Options
You can specify what particular exceptions to catch and an optional handler that is called when an exception is encountered.
class Song::Activity::Create < Trailblazer::Activity::Railway
step :create_model
step Rescue(RuntimeError, handler: MyHandler) {
step :upload
step :rehash
}
step :notify
fail :log_error
# ...
end
The handler’s #call
method receives the exception and the circuit-interface arguments.
class MyHandler
def self.call(exception, (ctx), *)
ctx[:exception_class] = exception.class
end
end
Alternatively, you can use a Callable
object for :handler
.
Full example
The Nested
, Wrap
and Rescue
macros can also be nested, allowing an easily extendable business workflow with error handling along the way.
TODO: add example
Policy
The Policy
macros Policy::Pundit
, and Policy::Guard
help to implement permission decider steps.
Pundit
The Policy::Pundit
module allows using Pundit-compatible policy classes in an operation.
A Pundit policy has various rule methods and a special constructor that receives the current user and the current model.
class MyPolicy
def initialize(user, model)
@user, @model = user, model
end
def create?
@user == Module && @model.id.nil?
end
def new?
@user == Class
end
end
In pundit policies, it is a convention to have access to those objects at runtime and build rules on top of those.
You can plug this policy into your pipe at any point. However, this must be inserted after the "model"
skill is available.
class Create < Trailblazer::Operation
step Model( Song, :new )
step Policy::Pundit( MyPolicy, :create? )
# ...
end
Note that you don’t have to create the model via the Model
macro - you can use any logic you want. The Pundit
macro will grab the model from ["model"]
, though.
This policy will only pass when the operation is invoked as follows.
Create.(current_user: User.find(1))
Any other call will cause a policy breach and stop the pipe from executing after the Policy::Pundit
step.
Add your polices using the Policy::Pundit
macro. It accepts the policy class name, and the rule method to call.
class Create < Trailblazer::Operation
step Model( Song, :new )
step Policy::Pundit( MyPolicy, :create? )
# ...
end
The step will create the policy instance automatically for you and passes the "model"
and the "current_user"
skill into the policies constructor. Just make sure those dependencies are available before the step is executed.
If the policy returns falsey
, it deviates to the left track.
After running the Pundit
step, its result is readable from the Result
object.
result = Create.(params: {}, current_user: Module)
result[:"result.policy.default"].success? #=> true
result[:"result.policy.default"][:policy] #=> #<MyPolicy ...>
Note that the actual policy instance is available via ["result.policy.#{name}"]["policy"]
to be reinvoked with other rules (e.g. in the view layer).
You can add any number of Pundit policies to your pipe. Make sure to use name:
to name them, though.
class Create < Trailblazer::Operation
step Model( Song, :new )
step Policy::Pundit( MyPolicy, :create?, name: "after_model" )
# ...
end
The result will be stored in "result.policy.#{name}"
result = Create.(params: {}, current_user: Module)
result[:"result.policy.after_model"].success? #=> true
Override a configured policy using dependency injection.
Create.(params: {},
current_user: Module,
:"policy.default.eval" => Trailblazer::Operation::Policy::Pundit.build(AnotherPolicy, :create?)
)
You can inject it using "policy.#{name}.eval"
. It can be any object responding to call
.
Guard
A guard is a step that helps you evaluating a condition and writing the result. If the condition was evaluated as falsey
, the pipe won’t be further processed and a policy breach is reported in Result["result.policy.default"]
.
class Create < Trailblazer::Operation
step Policy::Guard(->(options, pass:, **) { pass })
step :process
def process(options, **)
options[:x] = true
end
end
The only way to make the above operation invoke the second step :process
is as follows.
result = Create.({ pass: true })
result["x"] #=> true
Any other input will result in an abortion of the pipe after the guard.
result = Create.()
result["x"] #=> nil
result["result.policy.default"].success? #=> false
Learn more about → dependency injection to pass params and current user into the operation. TODO: fix link
The Policy::Guard
macro helps you inserting your guard logic. If not defined, it will be evaluated where you insert it.
step :process
def process(options, **)
options[:x] = true
end
The options
object is passed into the guard and allows you to read and inspect data like params
or current_user
. Please use kw args.
As always, the guard can also be a Callable
-marked object.
class MyGuard
def call(options, pass:, **)
pass
end
end
Insert the object instance via the Policy::Guard
macro.
class Create < Trailblazer::Operation
step Policy::Guard( MyGuard.new )
step :process
# ...
end
As always, you may also use an instance method to implement a guard.
class Create < Trailblazer::Operation
step Policy::Guard( :pass? )
def pass?(options, pass:, **)
pass
end
step :process
# ...
end
The guard name defaults to default
and can be set via name:
. This allows having multiple guards.
class Create < Trailblazer::Operation
step Policy::Guard( ->(options, current_user:, **) { current_user }, name: :user )
# ...
end
The result will sit in result.policy.#{name}
.
result = Create.(:current_user => true)
result[:"result.policy.user"].success? #=> true
Instead of using the configured guard, you can inject any callable object that returns a Result
object. Do so by overriding the policy.#{name}.eval
path when calling the operation.
Create.(
:current_user => Module,
:"policy.default.eval" => Trailblazer::Operation::Policy::Guard.build(->(options, **) { false })
)
An easy way to let Trailblazer build a compatible object for you is using Guard.build
.
This is helpful to override a certain policy for testing, or to invoke it with special rights, e.g. for an admin.
You may specify a position.
class Create < Trailblazer::Operation
step :model!
step Policy::Guard( :authorize! ),
before: :model!
end
Resulting in the guard inserted before model!
, even though it was added at a later point.
Trailblazer::Developer.railway(Create, style: :rows) #=>
# 0 ========================>operation.new
# 1 ==================>policy.default.eval
# 2 ===============================>model!
This is helpful if you maintain modules for operations with generic steps.