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.
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.
Only use the Nested()
macro if you’re planning to nest an activity that can only be chosen at runtime.
:auto_wire
option.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
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.
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.
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
.
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.
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.
ActiveRecord::Base.transaction
block, contains the yield
invocation that runs the wrapped steps.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.raise ActiveRecord::Rollback
will make ActiveRecord undo whatever database commits happened in the block.signal
, which is completely legit, Wrap()
also allows you to return a Right
or Left
signal to communicate the outcome of the entire Wrap()
component.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.
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.
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
def model(ctx, params:, **)
ctx[:model] = Song.find_by(id: params[:id])
end
include T.def_steps(:rearrange)
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()
.
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.
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
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
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
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"]]
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
}
ctx
, plus :index
and :item
.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
include EachTest::CoverMethods
include EachTest::ComposersForEach
end
end
:messages
will be initialized to an empty hash (unless you provide it in the outside ctx).#write_to_ctx
step within Each()
sees that :messages
variable and can override it, adding its non-sense message to the hash.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()
.
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.
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.
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.
In operations that target existing models, the Model::Find()
macro is the right tool to use.
When using this macro, a small nested activity is added as a step to your operation.
It comprises of two steps.
extract_id
which attempts to find the model’s ID in params
. This code can be configured using :params_key
or a manual block.find.Song
is the actual step running the finder code against your database.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 also implement the entire find logic manually using the :query
option.
module Song::Activity
class Update < Trailblazer::Activity::Railway
step Model::Find(
Song,
query: ->(ctx, id:, current_user:, **) { where(id: id, user: current_user).first }
)
step :validate
step :save
# ...
end
end
The lamdba you implement will be called directly on the model class, mimickin scope
from ActiveRecord.
The lambda receives ctx
and keyword options just like any other step in the operation, except for the :id
variable, which is the extracted ID from previous Find()
step.
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
# ...
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.
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
.
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
include T.def_steps(:validate, :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>
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
include T.def_steps(:validate, :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>
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>
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
include T.def_steps(:validate, :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
include T.def_steps(:validate, :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">
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
include T.def_steps(:validate, :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>
The following Model()
options can be injected using variables when call
ing the operation.
:"model.class"
which represents the first argument for Model()
.:"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: []
)
You can even leave Model()
entirely unconfigured.
module Song::Activity
class Create < Trailblazer::Activity::Railway
step Model()
step :validate
step :save
include T.def_steps(:validate, :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
include T.def_steps(:validate, :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)
Note that you don’t have to configure any model class at all when injecting it.
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.
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
.
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
The Policy
macros Policy::Pundit
, and Policy::Guard
help to implement permission decider steps.
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
.
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.
A contract is an abstraction to handle validation of arbitrary data or object state. It is a fully self-contained object that is orchestrated by the operation.
The actual validation can be implemented using Reform with ActiveModel::Validation
or dry-validation, or a Dry::Schema
directly without Reform.
The Contract
macros helps you defining contracts and assists with instantiating and validating data with those contracts at runtime.
Most contracts are Reform objects that you can define and validate in the operation. Reform is a fantastic tool for deserializing and validating deeply nested hashes, and then, when valid, writing those to the database using your persistence layer such as ActiveRecord.
# app/concepts/song/contract/create.rb
module Song::Contract
class Create < Reform::Form
property :title
property :length
validates :title, length: 2..33
validates :length, numericality: true
end
end
The contract then gets hooked into the operation.
class Song::Create < Trailblazer::Operation
step Model( Song, :new )
step Contract::Build( constant: Song::Contract::Create )
step Contract::Validate()
step Contract::Persist()
end
As you can see, using contracts consists of five steps.
Contract::Build
.Contract::Validate
.Contract::Persist
macro.You don’t have to use any of the TRB macros to deal with contracts, and do everything yourself. They are an abstraction that will save code and bugs, and introduce strong conventions. However, feel free to use your own code.
Here’s what the result would look like after running the Create
operation with invalid data.
result = Song::Create.(params: { title: "A" })
result.success? #=> false
result[:"contract.default"].errors.messages
#=> {:title=>["is too short (minimum is 2 characters)"], :length=>["is not a number"]}
Trailblazer offers a few different ways to define contract classes and use them in an operation.
The preferred way of defining contracts is to use a separate file and class, such as the example below.
# app/concepts/song/contract/create.rb
module Song::Contract
class Create < Reform::Form
property :title
property :length
validates :title, length: 2..33
validates :length, numericality: true
end
end
This is called explicit contract.
The contract file could be located just anywhere, but it’s clever to follow the Trailblazer conventions.
Using the contract happens via Contract::Build
, and the :constant
option.
class Song::Create < Trailblazer::Operation
step Model( Song, :new )
step Contract::Build( constant: Song::Contract::Create )
step Contract::Validate()
step Contract::Persist()
end
Since both operations and contracts grow during development, the completely encapsulated approach of the explicit contract is what we recommend.
Contracts can also be defined in the operation itself.
# app/concepts/song/create.rb
class Create < Trailblazer::Operation
class MyContract < Reform::Form
property :title
property :length
validates :title, presence: true
validates :length, numericality: true
end
step Model( Song, :new )
step Contract::Build(constant: MyContract)
step Contract::Validate()
step Contract::Persist( method: :sync )
end
Defining the contract happens via the contract
block. This is called an inline contract. Note that you need to extend the class with the Contract::DSL
module. You don’t have to specify anything in the Build
macro.
While this is nice for a quick example, this usually ends up quite convoluted and we advise you to use the explicit style.
The Contract::Build
macro helps you to instantiate the contract.
It is both helpful for a complete workflow, or to create the contract, only, without validating it, e.g. when presenting the form.
class Song::New < Trailblazer::Operation
step Model( Song, :new )
step Contract::Build( constant: Song::Contract::Create )
end
This macro will grab the model from options["model"]
and pass it into the contract’s constructor. The contract is then saved in options["contract.default"]
.
result = Song::New.(params: {})
result["model"] #=> #<struct Song title=nil, length=nil>
result["contract.default"]
#=> #<Song::Contract::Create model=#<struct Song title=nil, length=nil>>
The Build
macro accepts the :name
option to change the name from default
.
Instead of defining the contract class in the Build()
macro the very option can be injected at run-time, when call
ing the operation. The operation class doesn’t need any hard-wired reference to a contract class at all.
class Song::Create < Trailblazer::Operation
step Model(Song, :new)
step Contract::Build() # no constant provided here!
step Contract::Validate()
step Contract::Persist(method: :sync)
end
A prerequisite for that is that the contract class is defined somewhere.
class MyContract < Reform::Form
property :title
validates :title, length: 2..33
end
When invoking the operation, you now have to provide the default contract class as a variable (or dependency) using the :"contract.default.class"
option. The Build()
step will use the passed class constant for instantiating the contract.
Song::Create.(
params: { title: "Anthony's Song" },
"contract.default.class": MyContract # dependency injection!
)
This will work with any contract name if you follow the naming conventions.
The Contract::Validate
macro is responsible for validating the incoming params against its contract. That means you have to use Contract::Build
beforehand, or create the contract yourself. The macro will then grab the params and throw then into the contract’s validate
(or call
) method.
class Song::ValidateOnly < Trailblazer::Operation
step Model( Song, :new )
step Contract::Build( constant: Song::Contract::Create )
step Contract::Validate()
end
Depending on the outcome of the validation, it either stays on the right track, or deviates to left, skipping the remaining steps.
result = Song::ValidateOnly.(params: {}) # empty params
result.success? #=> false
Note that Validate
really only validates the contract, nothing is written to the model, yet. You need to push data to the model manually, e.g. with Contract::Persist
.
result = Song::ValidateOnly.(params: { title: "Rising Force", length: 13 })
result.success? #=> true
result[:model] #=> #<struct Song title=nil, length=nil>
result[:"contract.default"].title #=> "Rising Force"
Validate
will use options["params"]
as the input. You can change the nesting with the :key
option.
Internally, this macro will simply call Form#validate
on the Reform object.
Note that Reform comes with sophisticated deserialization semantics for nested forms, it might be worth reading a bit about Reform to fully understand what you can do in the Validate
step.
Per default, Contract::Validate
will use ctx[:params]
as the data to be validated. Use the :key
option if you want to validate a nested hash from the original params structure.
class Song::Create < Trailblazer::Operation
step Model( Song, :new )
step Contract::Build( constant: Song::Contract::Create )
step Contract::Validate( key: "song" )
step Contract::Persist( )
end
This automatically extracts the nested "song"
hash.
result = Song::Create.(params: { "song" => { title: "Rising Force", length: 13 } })
result.success? #=> true
If that key isn’t present in the params hash, the operation fails before the actual validation.
result = Song::Create.(params: { title: "Rising Force", length: 13 })
result.success? #=> false
Note that string vs. symbol do matter here since the operation will simply do a hash lookup using the key you provided.
The Validate()
macro allows to injecting the :key
option at run-time instead of providing it when using the macro on the class level. You may omit the :key
option in the Validate()
macro call as below.
class Song::Create < Trailblazer::Operation
# ...
step Contract::Validate() # we don't define a key here! E.g. {key: "song"}
step Contract::Persist()
end
Defining the :key
option in Operation.call
is now achieved by passing the "contract.default.extract_key"
option.
res = Song::Create.(
params: params,
"contract.default.extract_key": "song"
)
Note that the .default
part might differ depending on the name of your contract.
Instead of using Contract::Build()
to let the macro create the contract instance used for Validate()
, an arbitrary contract object may be injected at run-time. You may omit Model()
and Contract::Build()
in that case.
class Song::Create < Trailblazer::Operation
# we omit the {Model()} call as the run-time contract contains the model.
# we don't have a {Contract::Build()} step here.
step Contract::Validate(key: "song") # you could use an injection here, too!
step Contract::Persist()
end
In order for Validate()
to work you have to inject a contract via the :"contract.default"
option.
res = Song::Create.(
params: params,
"contract.default": Song::Contract::Create.new(Song.new) # we build the contract ourselves!
)
As always, the .default
part might differ depending on the name of your contract.
If the Contract::Validate()
deviates on a failure track, it is possible to emit a new signal suggesting contract failure.
This becomes really handy when used along with the endpoint.
It avoids any conditional checks and can be wired to render 422 response without accessing the ctx
.
In order to add this new termini in your operation’s terminuses, you need to pass invalid_data_terminus
kwarg.
class Song::Create < Trailblazer::Operation
step Model(Song, :new)
step Contract::Build(constant: Song::Contract::Create)
step Contract::Validate(key: :song, invalid_data_terminus: true)
step Contract::Persist()
end
Based on the given name to this macro (default is ofcourse, default
), it will assign End
semantic as "contract.#{name}.invalid"
.
result = Song::Create.(params: { title: "Rising Force", length: 13 })
result.success? #=> false
result.event #=> #<Trailblazer::Activity::End semantic=:"contract.default.invalid">
To push validated data from the contract to the model(s), use Persist
. Like Validate
, this requires a contract to be set up beforehand.
class Song::Create < Trailblazer::Operation
step Model( Song, :new )
step Contract::Build( constant: Song::Contract::Create )
step Contract::Validate()
step Contract::Persist()
end
After the step, the contract’s attribute values are written to the model, and the contract will call save
on the model.
result = Song::Create.(params: { title: "Rising Force", length: 13 })
result.success? #=> true
result["model"] #=> #<Song title="Rising Force", length=13>
You can also configure the Persist
step to call sync
instead of Reform’s save
.
step Persist( method: :sync )
This will only write the contract’s data to the model without calling save
on it.
Explicit naming for the contract is possible, too.
class Song::Create < Trailblazer::Operation
step Model( Song, :new )
step Contract::Build( name: "form", constant: Song::Contract::Create )
step Contract::Validate( name: "form" )
step Contract::Persist( name: "form" )
end
You have to use the name:
option to tell each step what contract to use. The contract and its result will now use your name instead of default
.
result = Song::Create.(params: { title: "A" })
result[:"contract.form"].errors.messages #=> {:title=>["is too short (minimum is 2 ch...
Use this if your operation has multiple contracts.
Instead of using ActiveModel::Validation
you may use the very popular dry-validation
gem for validations in your Reform class.
require "reform/form/dry"
class Create < Trailblazer::Operation
# contract to verify params formally.
class MyContract < Reform::Form
feature Dry
property :id
property :title
validation name: :default do
params do
required(:id).filled
end
end
validation name: :extra, if: :default do
params do
required(:title).filled(min_size?: 2)
end
end
end
step Model( Song, :new ) # create the op's main model.
step Contract::Build( constant: MyContract ) # create the Reform contract.
step Contract::Validate() # validate the Reform contract.
step Contract::Persist( method: :sync) # persist the contract's data via the model.
end
All you need to do is including the feature Dry
extension and applying dry-validation’s syntax within the validation
blocks. The operation’s macros will work seamlessly with dry’s logic.
(TODO Jan 2021) We are rewriting the Dry-validation documentation shortly and will link to a more concise reference here.
It is possible to use Dry’s Validation::Contract directly as a contract in an operation, using the Contract::Validate()
macro. You don’t need a model here, and the contract cannot be persisted. However, this is great for formal validations, e.g. to make sure the params have the correct format.
module Song::Operation
class Archive < Trailblazer::Operation
Schema = Dry::Validation.Contract do
params do
required(:id).filled
end
end
# step Model(Song, :new) # You don't need {ctx[:model]}.
step Contract::Validate(constant: Schema, key: :song) # Your validation.
# ...
end
end
Invoking the operation works exactly as it did with a “normal” contract.
result = Song::Operation::Archive.(params: {song: {id: nil}})
Note that if you don’t use the :song
key in your incoming data, you can configure the Validate()
macro to refrain from looking for that key.
The “errors” object after running the contract validation can be found at `ctx[:”result.contract.default”]
result[:"result.contract.default"].errors[:id] #=> ["must be filled"]
(TODO Jan 2021) We are working on an operation-wide Errors
object which will be available at ctx[:errors]
.
You can plug your own complex logic to extract params for validation into the pipe.
class Create < Trailblazer::Operation
class MyContract < Reform::Form
property :title
end
def type
"evergreen" # this is how you could do polymorphic lookups.
end
step Model( Song, :new )
step Contract::Build(constant: MyContract)
step :extract_params!
step Contract::Validate( skip_extract: true )
step Contract::Persist( method: :sync )
def extract_params!(options, **)
options[:"contract.default.params"] = options[:params][type]
end
end
Note that you have to set the self["params.validate"]
field in your own step, and - obviously - this has to happen before the actual validation.
Keep in mind that &
will deviate to the left track if your extract_params!
logic returns falsey.
To manually build the contract instance, e.g. to inject the current user, use builder:
.
class Create < Trailblazer::Operation
class MyContract < Reform::Form
property :title
property :current_user, virtual: true
validate :current_user?
validates :title, presence: true
def current_user?
return true if defined?(current_user)
false
end
end
step Model( Song, :new )
step Contract::Build( constant: MyContract, builder: :default_contract! )
step Contract::Validate()
step Contract::Persist( method: :sync )
def default_contract!(options, constant:, model:, **)
constant.new(model, current_user: options [:current_user])
end
end
Note how the contract’s class and the appropriate model are offered as kw arguments. You’re free to ignore these options and use your own assets.
As always, you may also use a proc.
The operation will store the validation result for every contract in its own result object.
The path is result.contract.#{name}
.
result = Create.(params: { length: "A" })
result[:"result.contract.default"].success? #=> false
result[:"result.contract.default"].errors #=> Errors object
result[:"result.contract.default"].errors.messages #=> {:length=>["is not a number"]}
Each result object responds to success?
, failure?
, and errors
, which is an Errors
object. TODO: design/document Errors. WE ARE CURRENTLY WORKING ON A UNIFIED API FOR ERRORS (FOR DRY AND REFORM).