Trailblazer

  • Last updated 25 Jun 20

About

The Trailblazer project started off with a single gem called trailblazer. Currently, the framework consists of around 40 gems. The main gem trailblazer is an umbrella gem with the sole reason to pull all default dependencies for you. Feel free to pick and choose what you need for your applications.

Overview

The following list of gems is an overview of the most important core components in Trailblazer.

Gem Summary Dependencies Source
CORE
trailblazer-activity Callable run-time circuits that control the execution flow of your tasks.
trailblazer-activity-dsl-linear DSL that provides Path, Railway and FastTrack activities.
trailblazer-context Option and context implementation.
HELPER
trailblazer-endpoint Implement the entire controller flow, from authentication until rendering, via activities.
trailblazer-operation Thin API around FastTrack exposing the "old" call-API named Operation.
trailblazer-macro Provides Nested(), Model() and other macros for everyday usage.
trailblazer-macro-contract Provides the Contract::Validate() macro and friends. reform, dry-validation
TOOLS
trailblazer-developer Activity visualization, tracing, debugging, PRO Editor communication.
trailblazer-test Minitest assertions and tools for fast TRB testing.
trailblazer-rspec Rspec testing extensions.
PRO
trailblazer-workflow Long-running activities with process engine, events and collaboration.
trailblazer-activity-implementation DSL for creating activities from PRO Editor exports.

Along with the core gems goes a rich list of eco-system gems.

Gem Summary Dependencies Source
reform Form objects.
cells Generic view components.
representable Maps representation documents from and to Ruby objects.
disposable Decorators on top of your ORM layer.
roar Parse and render REST API documents using representers.

Rails

Trailblazer runs with any Ruby web framework. However, if you’re using Rails, you’re lucky since we provide convenient glue code in the trailblazer-rails gem.



gem "trailblazer-rails"

todo: add versioning information

Loader

The trailblazer-loader gem implements a very simple way to load all files in your concepts directory in a heuristically meaningful order. It can be used in any environment.

The trailblazer-loader gem comes pre-bundled with trailblazer-rails for historical reasons: in the early days of Trailblazer, the conventional file name concepts/product/operation/create.rb didn’t match the short operation name, such as Product::Create.

The trailblazer-loader gem’s duty was to load all concept files without using Rails’ autoloader, overcoming the latter’s conventions.

Over the years, and with the emerge of controller helpers or our workflow engine calling operations for you, the class name of an operation more and becomes a thing not to worry about.

Many projects use Trailblazer along with the Rails naming convention now. This means you can disable the loader gem, and benefit from Rails auto-magic behavior such as faster loading in the “correct” order, reloading and all the flaws that come with this non-deterministic behavior.

As a first step, add Operation to your operation’s class name, matching the Rails naming convention.



# app/concepts/product/operation/create.rb

module Product::Operation
  class Create < Trailblazer::Operation
    # ...
  end
end

It’s a Trailblazer convention to put [ConceptName]::Operation in one line: it will force Rails to load the concept name constant, so you don’t have to reopen the class yourself.

This will result in a class name Product::Operation::Create.

Next, disable the loader gem, in config/initializers/trailblazer.rb.



# config/initializers/trailblazer.rb

YourApp::Application.config.trailblazer.enable_loader = false

Trailblazer files will now be loaded by Rails - you need to follow the Rails autoloading file naming from here on, and things should run smoothly. A nice side-effect here is that in bigger projects (with hundreds of operations), the start-up time in development accelerates significantly.

The infamous warning: toplevel constant Cell referenced by Notification::Cell warning is a bug in Ruby. You should upgrade to Ruby >= 2.5.

2.1 Migration

Call API

In versions before 2.1, the automatic merging of the params part and the additional options was confusing many new users and an unnecessary step.



# old style
result = Memo::Create.( params, "current_user" => current_user )

The first argument (params) was merged into the second argument using the key “params”. You now pass one hash to call and hence do the merging yourself.



# new style
result = Memo::Create.( params: params, current_user: current_user )

The new call API is much more consistent and takes away another thing we kept explaining to new users - an indicator for a flawed API.

For a soft deprecation, do this in an initializer:



require "trailblazer/deprecation/call"

You will get a bunch of warnings, so fix all Operation.() calls and remove the require again. This is provied by trailblazer-future gem.

In case your steps expose a signature as follows, you’re safe.



class Memo::Create < Trailblazer::Operation
  step :create_model

  def create_model(options, params:, **)
    # ..
  end
end

By convention, we renamed option to ctx, but it is completely up to you to adopt this.

Nevertheless, the “old style” signatures won’t work anymore.



class Memo::Create < Trailblazer::Operation
  def create_model(options)
    # ..
  end

  # or

  def create_model(model:, **)
    # ..
  end
end

Neither a single options nor keyword-arguments-only are gonna fly as the new step signature is more restrictive and always requires you to maintain the ctx (or options, if you will) as the first positional argument, then keyword arguments.

Double splat operator ** at the end will be required to in order to discard unused kw args.

You can introduce this change before actual migration to 2.1.

Steps declared as success or failure are now renamed to pass and fail respectively.



class Memo::Create < Trailblazer::Operation
  success :model
  failure :handle_some_error
end

Change it as follows.



class Memo::Create < Trailblazer::Operation
  pass :model
  fail :handle_some_error
end

If you are using Rubocop it will probably start complaining about unreachable code because it just so happens that fail is also a Ruby Kernel’s method. One solution to this could be to add a custom rule to .rubocop.yml like this:



Lint/UnreachableCode:
  Exclude:
    - '*/**/concepts/**/operation/**/*'
Style/SignalException:
  Exclude:
    - '*/**/concepts/**/operation/**/*'

There is also a trick that will allow you to do this rename before actually migrating to 2.1. You can put this in an initializer:



Trailblazer::Operation::Pipetree::DSL.send(:alias_method, :pass, :success)
Trailblazer::Operation::Pipetree::DSL.send(:alias_method, :fail, :failure)

This way you could introduce this change before actual migration to 2.1. Just don’t forget to remove it after updating gems to 2.1.

Now every step that may end up in pass_fast or fail_fast signals need an extra option that indicates fast track usage. Change this operation:



class Memo::Create < Trailblazer::Operation
  step :create

  def create(ctx, **)
    Railway.pass_fast! if ctx[:model].save
  end
end

Use the :fast_track option to let Trailblazer know about the potential new signal being emitted.



class Memo::Create < Trailblazer::Operation
  step :create, fast_track: true # notice the extra option :fast_track

  def create(ctx, **)
    Railway.pass_fast! if ctx[:model].save
  end
end

Context

The keys for ctx used to be mixed up, some where "longer.strings", some were :symbols. The new context implementation Context::IndifferentAccess now allows to use both.



ctx["model"]
ctx[:model]  # => same!

This also works for namespaced keys, which you still might find helpful.



ctx["contract.default"]
ctx[:"contract.default"]  # => same!

On the core level, we use symbol keys, only (e.g. :"contract.default").

The default implementation of the context object can be set by overriding Context.implementation. For example, if you want the old behavior back.



class Trailblazer::Context
  def self.implementation
    Context # old behavior.
  end
end

Note that the override might be deprecated in favor of a dependency injection.

Nested

The Nested macro allows to, well, nest activities or operations, providing a neat way to encapsulate and reuse logic.

In 2.1, the [Subprocess macro] is the standard way for nesting. The Nested macro should only be used when you use the dynamic version where the nested operation is computed at runtime using :builder.

An exception will warn you about the inappropriate usage.



[Trailblazer] Using the `Nested()` macro with operations and activities is deprecated. Replace `Nested(Create)` with `Subprocess(Create)`.

Both the :input and :output options that used to go with Nested(Present, :input: ...) are now a generic option in Trailblazer. Move them behind the macro parenthesis.



# 2.0
Nested(Present, input: :my_input, output: :my_output)

# 2.1
Nested(Present), input: :my_input, output: :my_output

An exception will stop compilation if you fail to obey.



ArgumentError: unknown keyword: input

Don’t forget to declare fast track usage if you expect it from withing nested operation, like this:



Nested(Present), input: :my_input, output: :my_output, fast_track: true

Another difference is that in 2.0, when you were using pass_fast in nested operation, it would stop only nested operation from executing. After this outer one would continue executing.

Now returning pass_fast in nested operation will stop both, inner and outer operation with success as a final result. If you rely on old behaviour you can still have it with proper signals mapping:



Nested(Present), input: :my_input,
                 output: :my_output,
                 fast_track: true,
                 Output(:pass_fast) => Track(:success), # pass_fast now mapped to `just` a success
                 Output(:fail_fast) => End(:failure)

Macro API

Macros are functions that add a task along with specific options to the activity. In TRB 2.0, those (historically camel-cased) functions returned an array with two elements.



module MyMacro
  def self.NormalizeParams(name: :myparams, merge_hash: {})
    task = ->((ctx, flow_options), _) do
      ctx[name] = ctx[:params].merge(merge_hash)

      return Trailblazer::Activity::Right, [ctx, flow_options]
    end

    [task, name: name] # old API
  end
end

In 2.1, a hash is returned. Note that :name is :id now.


module MyMacro
  def self.NormalizeParams(name: :myparams, merge_hash: {})
    task = ->((ctx, flow_options), _) do
      ctx[name] = ctx[:params].merge(merge_hash)

      return Trailblazer::Activity::Right, [ctx, flow_options]
    end

    # new API
    {
      task: task,
      id:   name
    }
  end
end

This allows for a much richer macro experience where you might add additional steps via a macro, use DSL options such as :before and :after and add taskWrap extensions. [macro API]

In case you used the same macro twice in one operation, like this for example:



class Create < Trailblazer::Operation
  step Contract::Persist(method: :sync)
  step Contract::Persist(method: :save!)
end

You will have to specify id explictly for one of them:



class Create < Trailblazer::Operation
  step Contract::Persist(method: :sync)
  step Contract::Persist(method: :save!), id: "some_custom_unique_id"
end

Contract DSL

It was possible to define contracts on the operation level using a DSL.



class Create < Trailblazer::Operation
  contract do
    property :id
  end

  step Contract::Build()
  step Contract::Validate()
end

Since the usability doesn’t outweigh the complexity needed to implement such DSL, we decided to remove that functionality for now.

Instead, use an explicit inline class and the :constant option.



class Create < Trailblazer::Operation
  class Form < Reform::Form
    property :id
  end

  step Contract::Build(constant: Form)
  step Contract::Validate()
end

Trailblazer loader

Usage of a trailblazer-loader is now discouraged as it’s slower than the ones provided by Rails and it’s error prone.

In short, we decided to adopt the Rails naming scheme and change operation names from User::Create to User::Operation::Create, so the file name and class path are in sync.

Read the details here.

We highly recommend changing this with your upgrade as it highly improves the dev experience.

Developer

Trailblazer provides a rich set of developer tools to ease debugging and make modelling processes a pleasant experience.

The developer gem contains tools to help you visualize and debug Trailblazer code. Its development features such as tracing or exception handling are advanced tools and will become your best friends very quickly.

Constant

We advise you to alias the Developer constant to something handy, such as ::Dev in your project. For instance, in Rails you’d have the following line in a config/initializers/trailblazer.rb initializer.


# config/initializers/trailblazer.rb
require "trailblazer/developer"
Dev = Trailblazer::Developer

Wtf?

Use wtf? if you want to

  • Debug an exception happening somewhere deep in your code.
  • Find out which step changed the track to failure.
  • Focus on specific step(s) to find out what ctx mutations are happening inside them.

This will run your activity with tracing enabled, and in case of an exception, print the trace path to the closest point where the exception was thrown.

Your activity will be called using the TaskWrap invoker, a possible exception will be caught and the closest task found. It then prints the “stack trace”, however, on a much higher level based on tasks, not methods.

Please note that this is a higher-level debugging tool that does not confront you with a 200-lines stack trace the way Ruby does it, but pinpoints the exceptional code and locates the problem on a task level.

This is possible due to you structuring code into higher abstractions, tasks and activities.

You can focus on specific steps and variables to find out what ctx comes in and what goes out. The focus_on option allows us to capture any key(s) from ctx and print any mutations happening within given steps.

You need to pass one or more step names (either default name or given by explicit :id) to capture the mutations.


class Memo::Create < Trailblazer::Activity::Path
  step :validate
  step :create, id: :create_memo
  # ...
end

Dev.wtf?(Memo::Create, [ctx, { focus_on: { steps: [:validate, :create_memo] } }])

By default, focusing will capture whole ctx for given steps. But you can also filter on it by passing one or more keys using variables.


Dev.wtf?(Memo::Create, [ctx, { focus_on: { variables: [:params], steps: :validate } }])

Internally, when focus_on option is passed, we call Object#inspect method to collect before-after data from the ctx for given steps. This data is then used while rendering the trace. In case you want to customize the data collection behaviour with your own logic, you can pass the default_inspector.


Dev.wtf?(
  Memo::Create,
  [
    { params: { text: 'Hydrate!', value: nil } },
    {
      focus_on: { steps: :validate, variables: :params },
      default_inspector: ->(value){ value.nil? ? 'UNKNOWN' : value.inspect }
    }
  ]
)

Inspection runs deeply when ctx contains nested structures like hashes or arrays. It’s concept is very simple and handled in Dev::Trace::Inspector module.

The sole purpose to add Dev::Trace::Inspector module is to make custom inspection possible and efficient while tracing. For example, ActiveRecord::Relation#inspect makes additional queries to fetch top 10 records and generate the output everytime.


To avoid this, Inspector will not call inspect method when it finds such objects (deeply nested anywhere).

Instead, it’ll call AR::Relation#to_sql to get plain SQL query which doesn’t make additional queries and is better to understand in tracing output. There is always a possibility to cater such cases and make wtf tracing better. Contributions are welcome :)

The color_map option allows you to customize default coloring scheme being used to render passed or failed steps.

Render

puts Trailblazer::Developer.render(Memo::Create)

Render Linear

Client

The Developer::Client module provides functions to work with activities and workflows created in the PRO editor.

Notes

  • IDs are extracted from the label of the element. They get chomped to remove a potential newline at the end and reduce confusion.
  • It is currently not possible to assign semantics to arrows via the label. In the meantime, use the :query option. # TODO

Context

Test

In Trailblazer, you write operation and integration tests. Operations encapsulate all business logic and are single-entry points to operate your application. There’s no need to test controllers, models, service objects, etc. in isolation - unless you want to do so for a better documentation of your internal APIs.

However, the idea of operation tests is: Run the complete unit with a certain input set, and test the side-effects. This differs to the Rails Way™ testing style, where smaller units of code, such as a specific validation or a callback, are tested in complete isolation. While that might look tempting and clean, it will create a test environment that is not identical to what happens in production.

In production, you will never trigger one specific callback or a particular validation, only. Your application will run all code required to create a Song object, for instance. In Trailblazer, this means running the Song::Create operation, and testing that very operation with all its side-effects.

Luckily, trailblazer-test provides a simple abstraction allowing to run operations and test all side-effects without creating verbose, unmaintable test code.

Testing Trailblazer applications usually involves the following tests.

  1. Unit tests for operations: They test all edge cases in a nice, fast unit test environment without any HTTP involved.
  2. Integration tests for controllers: These Smoke tests only test the wiring between controller, operation and presentation layer. Usually, a coded click path simulates you manually clicking through your app and testing if it works. The preferred way here is using Rack-test and Capybara.
  3. Unit tests for cells By invoking your cells with arbitrary data you functionally test the rendered markup using Capybara.

Installation

Add this line to your application’s Gemfile:

gem 'trailblazer-test'

And then execute:

$ bundle

Or install it yourself as:

$ gem install trailblazer-test

Assertions

To use available assertions, add in your test _helper the following modules:

include Trailblazer::Test::Assertions
include Trailblazer::Test::Operation::Assertions

If you are using Trailblazer v2.0 you need to add also:

require "trailblazer/test/deprecation/operation/assertions"

include Trailblazer::Test::Deprecation::Operation::Assertions # in your test class

To be able to test an operation we need 3 auxiliary methods which have to be defined at the start of your tests:

  • default_params (required): hash of params which will be always passed to the operation unless overriden by params or ctx
  • expected_attrs (required): hash always used to assert model attributes
  • default_options (required if using ctx): hash of options which will be always passed to the operation unless overriden by ctx

We are also providing 2 helper methods:

  • params(new_params)
  • ctx(new_params, options)

Those will merge params and options for you and return the final inputs which then can be passed to the operation under testing.

Use assert_pass to run an operation and assert it was successful, while checking if the attributes of the operation’s model are what you’re expecting.


describe "Create with sane data" do
  let(:default_params) { {band: "Rancid"} }
  let(:expected_attrs) { {band: "Rancid", title: "Timebomb"} }

  # just works
  it { assert_pass Create, params(title: "Ruby Soho"), title: "Ruby Soho" }
  # trimming works
  it { assert_pass Create, params(title: "  Ruby Soho "), title: "Ruby Soho" }
end

Both default_params and expected_attrs have to be made available via let to provide all default data. They will automatically get merged with the data per test-case. default_params will be merged with the params passed into the operation call, expected_attrs represent your expected outcome.

Pass deep_merge: false to disable the deep merging of the third argument expected_attributes and the auxiliary method expected_attrs.

The second test case would resolve to this manual test code.

it do
  result = Create( band: "Rancid", title: "  Ruby Soho " )

  assert result.success?
  assert_equal "Rancid",   result["model"].band
  assert_equal "Timebomb", result["model"].title
end

As you can see, assert_pass drastically reduces the amount of test code.

assert_pass: Block

If you need more specific assertions, use a block with assert_pass.


describe "Update with sane data" do
  let(:default_params) { {band: "Rancid"} }
  let(:expected_attrs) { {band: "Rancid", title: "Timebomb"} }

  it do
    assert_pass Update, ctx(title: " Ruby Soho"), {} do |result|
      assert_equal "Ruby Soho", result[:model].title
    end
  end
end

Here, the only assertion made automatically is whether the operation was run successfully. By yielding the result object in case of success, all other assertions can be made manually.

To test an unsuccessful outcome of an operation, use assert_fail. This is used for testing all kinds of validations. By passing insufficient or wrong data to the operation, it will fail and mark errors on the errors object.


describe "Update with invalid data" do
  let(:default_params) { {band: "Rancid"} }

  it { assert_fail Update, ctx(band: "Adolescents"), expected_errors: [:band] }
end

Here, your params are merged into default_params and the operation is called. The first assertion is whether result.failure? is true.

After that, the operation’s error object is grabbed. With an array as the third argument to assert_fail this will test if the errors object keys and your expected keys of error messages are equal.

In 2.0 and 2.1, the errors object defaults to result["contract.default"].errors. In TRB 2.2, there will be an operation-wide errors object decoupled from the contracts.

This roughly translates to the following manual test case.

it do
  result = Comment::Create( band: "  Adolescents", title: "Timebomb" )
                                        # Timebomb is a Rancid song.
  assert result.failure?
  assert_equal [:band], result["contract.default"].errors.messages.keys
end

Per default, no assumptions are made on the model.

assert_fail: Block

You can use a block with assert_fail.


describe "Update with invalid data" do
  let(:default_params) { {band: "Rancid"} }

  it do
    assert_fail Update, ctx(band: " Adolescents") do |result|
      assert_equal({band: ["must be Rancid"]}, result["contract.default"].errors.messages)
    end
  end
end

Only the failure? outcome is asserted here automatically.

This will test that the operation fails due to a policy failure.


describe "Update with failing policy" do
  let(:default_params) { {band: "Rancid"} }
  let(:not_allowed_user) { Struct.new(:name).new("not_allowed") }

  it do
    assert_policy_fail Update, ctx({title: "Ruby Soho"}, current_user: not_allowed_user)
  end
end

Add this in your test file to be able to use it:

include Trailblazer::Test::Operation::PolicyAssertions

Change policy name using policy_name.


assert_policy_fail CustomUpdate, ctx({title: "Ruby Soho"}, current_user: not_allowed_user), policy_name: "custom"

Test attributes of an arbitrary object.

Pass a hash of key/value tuples to assert_exposes to test that all attributes of the asserted object match the provided values.


it do
  assert_exposes model, title: "Timebomb", band: "Rancid"
end

Per default, this will read the values via model.{key} from the asserted object (model) and compare it to the expected values.

This is a short-cut for tests such as the following.

assert_equal "Timebomb", model.title
assert_equal "Rancid",   model.band

Note that assert_exposes accepts any object with a reader interface.

assert_exposes: reader

If the asserted object exposes a hash reader interface, use the :reader option.


it do
  assert_exposes model, {title: "Timebomb", band: "Rancid"}, reader: :[]
end

This will read values with via #[], e.g. model[:title].

If the object has a generic reader, you can pass the name via :reader.


it do
  assert_exposes model, {title: "Timebomb", band: "Rancid"}, reader: :get
end

Now the value is read via model.get(:title).

assert_exposes: Lambda

You can also pass a lambda to assert_expose in order to compute a dynamic value for the test, or for more complex comparisons.


it do
  assert_exposes model, title: "Timebomb", band: ->(actual:, **) { actual.size > 3 }
end

The lambda will receive a hash with the :actual value read from the asserted object. It must return a boolean.

Helpers

There are several helpers to deal with operation tests and operations used as factories.

Add this in your _helper.rb file to use all available helpers.

include Trailblazer::Test::Operation::Helper

Instead of manually invoking an operation, you can use the call helper.


it "calls the operation" do
  result = call Create, params: {title: "Shipwreck", band: "Rancid"}

  assert_equal true, result.success?
end

This will call the operation and passes through all other arguments. It returns the operation’s result object, which allows you to test it.

result.success? #=> true
result["model"] #=> #<Song id=1, ...>

You should always use operations as factories in tests. The factory method calls the operation and raises an error should the operation have failed. If successful, it will do the exact same thing call does.


it "calls the operation and raises an error and prints trace when fails" do
  exp = assert_raises do
    factory Create, params: {title: "Shipwreck", band: "The Chats"}
  end

  exp.inspect.include? %(Operation trace)
  exp.inspect.include? "OperationFailedError: factory(Create) has failed due to validation "\
                       "errors: {:band=>['must be Rancid']}"
end

If the factory operation fails, for example due to invalid form input, it raises a OperationFailedError exception.

factory( Song::Create, { title: "" } )["model"]
#=> Trailblazer::Test::OperationFailedError: factory( Song::Create ) failed.

It is absolutely advisable to use factory in combination with let.

let(:song) { factory( Song::Create, { title: "Timebomb", band: "Rancid" } ) }

Also, you can safely use FactoryGirl’s attributes_for to generate input.

params accepts one argument which is merged into default_params.

let(:default_params) { { title: 'My title' } }

params(artist: 'My Artist') # => { params: { title: 'My title', artist: 'My Artist' } }
params(title: 'Other one')  # => { params: { title: 'Other one' } }

ctx accepts 2 arguments, first one will be merged into the default_params and the second one will be merged into default_options

let(:default_params)  { { title: 'My title' } }
let(:default_options) { { current_user: 'me' } }

ctx(artist: 'My Artist')                        # => { params: { title: 'My title', artist: 'My Artist' }, current_user: 'me' }
ctx({title: 'Other one'}, current_user: 'you')  # => { params: { title: 'Other one' }, current_user: 'you' }

This helper allows you to mock any step within a given or deeply nested activities. For example,


class Show < Trailblazer::Activity::FastTrack
  class Complexity < Trailblazer::Activity::FastTrack
    class ExternalApi < Trailblazer::Activity::FastTrack
      step :make_call
      # ...
    end

    step :some_complex_task
    step Subprocess(ExternalApi)
    # ...
  end

  step :load_user
  step Subprocess(Complexity)
  # ...
end

To skip processing inside :load_user and use a mock instead, use mock_step.


it "mocks loading user" do
  new_activity = mock_step(Show, id: :load_user) do |ctx|
    ctx[:user] = Struct.new(:name).new('Mocky')
  end

  assert_pass new_activity, default_params, {} do |(signal, (ctx, flow_options))|
    assert_equal ctx[:user].name, 'Mocky'

    assert_equal ctx[:seq], [:some_complex_task, :make_call]
  end
end

Internally, it creates and returns a fresh, subclassed activity (via patching) whilst replacing the step for given :id. Be advised that this does not change the original activity class.

You can also mock any nested activity (aka Subprocess) which does any heavy computations or I/O calls.


new_activity = mock_step(Show, id: Show::Complexity) do
  true # no-op to avoid any Complexity
end

In case you want to mock only single step from the nested activity, you can do so by passing it as a subprocess.


new_activity = mock_step(Show, id: :some_complex_task, subprocess: Show::Complexity) do
  # Mock only single step from nested activity to do nothing
  true
end

It’ll search the :id to be mocked within nested activity instead of top-level activity.

In addition, if you want to mock any deeply nested step in subprocess's activity, it can be done via passing subprocess_path.


new_activity = mock_step(Show, id: :make_call, subprocess: Show::Complexity, subprocess_path: [Show::Complexity::ExternalApi]) do
  # Some JSON response
end

subprocess_path should list n-level of nested activities in the order they are nested. Internally, it uses patching API supported by Subprocess helper.