The endpoint gem provides abstractions to invoke operations, configure injected options, and gives the invoker a matcher-like interface to deal with the outcome.
It also introduces a separate layer allowing to maintain “operations around” your actual business operation. This is called protocol.
In many TRB-driven projects, developers override Operation.call_with_public_method
to inject options and tweak the way an operation is invoked on the top-level. For example, adding ctx aliases or setting standard variables in the ctx can be achieved with a monkey-patch.
However, this patch is limited and not the recommended way to configure the invocation. With endpoint
, we’re introducing a canonical abstraction for invoking operations and activities, and customizing their options.
This has three major advantages.
signal, result = __(Memo::Operation::Create, params: {memo: {}}) do
failure { |ctx, contract:, **| puts contract.errors }
end
TODO: :default_matcher :flow_options how to use in tests via trailblazer-test.
In order to use the “canonical invoke”, you need to define the #__()
method (or whatever name you want) and configure its keyword arguments.
# config/initializers/trailblazer.to
include Trailblazer::Endpoint::Runtime::TopLevel
def __(operation, ctx, flow_options: GLOBAL_FLOW_OPTIONS, **, &block)
super
end
# put whatever is needed here.
GLOBAL_FLOW_OPTIONS = {
context_options: {
aliases: {"model": :record},
container_class: Trailblazer::Context::Container::WithAliases,
}
}
Your entire application can now use #__()
and #__?()
to invoke operations that have set a ctx alias :record
for :model
.
This might change in the future as we’re considering overriding #call_with_public_interface
directly.
The layer helping you in controllers provides the canonical invoke via the #invoke
method, and offers a super simple and customizable DSL to configure injected options.
Include the Controller
module into your ApplicationController
to use the endpoint controller layer across your app.
class ApplicationController < ActionController::Base
# ...
include Trailblazer::Endpoint::Controller.module
# ...
end
Your controller and all subclasses are now ready to configure and run endpoints.
To use the canonical invoke along with all its mechanics, use the #invoke
method in your controller actions. It will automatically inject the well-defined options for each operation call, and accepts an optional block to handle different outcomes.
class MemosController < ApplicationController
# ...
def create
invoke Memo::Operation::Create, params: params, protocol: false do # FIXME: protocol should be false per default.
success { |ctx, model:, **| redirect_to memo_path(id: model.id) }
failure { |ctx, contract:, **|
render partial: "form", locals: {contract: contract}
}
end
end
# ...
end
In the example above, input for the operation, such as :params
, needs to be handed manually into #invoke
.
Also, every possible outcome has to be defined by hand in the matcher block, cluttering the simple controller action with verbose code.
However, it’s actually very simple to move the reappearing options and matchers one level up using the configuration DSL.
You can predefine endpoint-specific options within the endpoint
block of a controller. Usually, that would be done on the ApplicationController
level.
class ApplicationController < ActionController::Base
include Trailblazer::Endpoint::Controller.module
endpoint do
ctx do
# ...
end
default_matcher do
# ...
end
flow_options do
# ...
end
end
The various directives are discussed in the following sections.
Use the ctx
block to define what ctx variables always get passed into the operation invocation.
endpoint do
# ...
ctx do |controller:, **|
{
params: controller.params,
}
end
# ...
end
Note that you have access to :controller
and :activity
as block keyword arguments. Any number of ctx variables may be added, such as the current_user
.
Now, the controller action doesn’t need to pass params
anymore.
def create
invoke Memo::Operation::Create, protocol: false do
# ...
end
end
Variables passed manually to #invoke
are merged with the endpoint.ctx
hash.
Using the endpoint
DSL, you can keep controller actions lean by defining generic handlers once, on the ApplicationController
level using the default_matcher
block.
endpoint do
# ...
default_matcher do
{
failure: ->(ctx, contract:, **) {
render partial: "form", locals: {contract: contract}
},
not_found: ->(ctx, **) { head 404 }
}
end
# ...
end
The hash defines handlers keyed by terminus names. The handler block receives the ctx
along with keyword arguments, returned by the operation.
The block is executed in the controller context, allowing you to use its internal API, e.g. #render
or #head
.
With the above configuration, the controller action now only needs one matcher.
def create
invoke Memo::Operation::Create, protocol: false do
success { |ctx, model:, **| redirect_to memo_path(id: model.id) }
end
end
Note that a default success
handler is overridden by the block passed to #invoke
.
TODO
Per default, all configuration options are inherited, allowing you very lean concrete controllers that only override specific directives.
class MemosController < ApplicationController
# ...
end
MemosController
inherits all endpoint
options from ApplicationController
and allows overriding those.
Whenever you need an operation “around” your actual business operation, to add authentication, policy checks, finding models, or other steps before and after your domain code, you want to start using a protocol endpoint. We usually simply call those endpoint.
A protocol endpoint is defined per use case, which usually corresponds to a particular controller action and operation. In this example, an Update
operation is embraced by its endpoint. You may add arbitrary steps to the endpoint, such as authentication, authorization or data filtering that is unrelated to your business domain.
Usually, a bunch of use cases share the same protocol.
Here, two exemplary steps authenticate
and policy
are placed before the actual business operation Memo::Operation::Update
. Also note how both “protocol steps” have their failure
output connected to their own terminus. The layout of the endpoint, nevertheless, is completely up to you.
Endpoints define a fixed set of possible outcomes: besides success
and failure
there might be additional, more fine-grained termini like not_found
or even credit_card_invalid
. It’s the job of your team to decide which generic, reusable termini to introduce.
The key idea of a fixed set of outcomes is a fixed set of handlers. You implement the handlers for each terminus once, and override those in specific cases per controller or controller action. In other words, not_found
renders a 404 error automatically, but you can customize the rendering in a success
outcome manually.
Endpoints can be used anywhere, in controllers, tests, background jobs, or wherever you need to invoke an operation. This example showcases a typical Rails controller using an operation Memo::Operation::Create
along with our self-tailored protocol. The latter might look like so.
class MyProtocol < Trailblazer::Activity::Railway # two termini.
step :authenticate # our imaginary logic to find {current_user}.
step nil, id: :domain_activity # {Memo::Operation::Create} gets placed here.
def authenticate(ctx, request:, **)
ctx[:current_user] = AuthorizeFromJWT.(request)
end
end
The Protocol
, for obvious reasons, needs to be an Activity
subclass like Railway
or FastTrack
.
The steps involved, the termini exposed, all that is up to the designer. Only one step named :domain_activity
is mandatory as this step gets replaced with the actual business operation.
Use the controller method ::endpoint
to compile an actual endpoint with your MyProtocol
and a concrete operation.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Create, protocol: MyProtocol # define endpoint.
# ...
end
A new activity is created where MyProtocol
now contains Memo::Operation::Create
as its :domain_activity
step. This is done on the class level so it’s compiled and stored once.
To run the protocol with your operation, use #invoke
. This part is (almost) identical to when running an operation without a protocol.
class MemosController < ApplicationController
# ...
def create
invoke Memo::Operation::Create, protocol: true, request: request, params: params do
success { |ctx, model:, **| redirect_to memo_path(id: model.id) }
failure { |ctx, params:, **| head 401 }
end
end
# ...
end
With protocol: true
, the invocation logic will grab the endpoint you created earlier. Check the #wtf?
trace to understand what happens here.
As planned, the business operation is run as one step of the protocol.
This example is very verbose. You can move many options onto the controller level. This is discussed in the Configuration section.
One main reasons to use protocols is a fixed set of termini, which allows you to create generic default matchers to handle outcomes. This reduces application code in your controllers and saves developers from having to think about possible outcomes.
When creating a protocol for a segment of your application, most of the thinking will be about which termini to expose and how to connect them. You can use any feature of the Wiring API - in the end, a protocol is just another “operation”.
class MyProtocol < Trailblazer::Activity::Railway
# add additional termini.
terminus :not_found
terminus :not_authenticated
terminus :not_authorized
step :authenticate, Output(:failure) => Track(:not_authenticated)
step nil, id: :domain_activity # {Memo::Operation::Create} gets placed here.
def authenticate(ctx, params:, **)
# ...
end
end
The above example protocol exposes five termini. Three extra termini are added using #terminus
to Railway’s success
and failure
tuple.
To re-route the failure
output of the authenticate
step, the Wiring API is leveraged and connects it directly to the new terminus not_authenticated
.
Five termini to rule them all.
success
obviously indicates everything went fine.failure
informs the caller of a validation error.not_found
could be terminated on when a model wasn’t found.current_user
couldn’t be computed, due to invalid credentials, the not_authenticated
terminus is hit.not_authorized
.Now, exposing this kind of layout is only helpful if you actually handle each case in a specific way. For a better understanding, here’s an example matcher block.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Create, protocol: MyProtocol
def create
invoke Memo::Operation::Create, protocol: true, params: params do
success { |ctx, model:, **| redirect_to memo_path(model.id) }
failure { |ctx, contract:, **| render partial: "form", object: contract }
not_authorized { |**| head 403 }
not_authenticated { |**| head 401 }
not_found { |ctx, **| render file: PAGE_404, status: :not_found }
end
end
# ...
end
This #create
controller action illustrates nicely how endpoint’s matcher interface allows dealing with outcomes (or different termini). Check the default_matcher
directive to learn how to reduce redundancy and be less verbose with your matchers.
When compiling an endpoint with MyProtocol
, every “known” terminus of the business operation is automatically connected.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Update, protocol: MyProtocol
# ...
end
For example, if Update
was exposing a not_found
terminus, it’s linked to its respective terminus in the protocol.
In case you need to connect an output to another arbitrary track or terminus, you can use the Wiring API in an optional block when compiling the endpoint.
Let’s assume the Update
operation was terminating on its built-in fail_fast
terminus when the updated model couldn’t be found. You can wire that very terminus to the not_found
track in the protocol using the Wiring API.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Update, protocol: MyProtocol do
{
Output(:fail_fast) => Track(:not_found)
}
end
# ...
end
The returned hash of wiring instructions is executed when the :domain_activity
placeholder step is replaced with the actual business operation at compile time, making the diagram look as follows.
This trick allows you to “translate” any kind of operation layout to your protocol’s. For instance, you could even move legacy operations into endpoints and connect their termini to corresponding tracks in the protocol.
Any configuration directive from the controller layer can be used in combination with a protocol. In addition to that, we do have some extra options to fine-tune the compilation of your protocol endpoints.
Use the options
directive to default options when you create a new endpoint on the controller level.
class MemosController < ApplicationController
# ...
endpoint do
options do
{
protocol: MyProtocol,
}
end
end
# ...
end
With the above setting, you can skip the :protocol
option.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Create # no :protocol needed!
# ...
end
Note that you can still manually override the class-wide option when defining the endpoint.
If you’re not interested in handling your operation’s pass_fast
and fail_fast
termination separately, you can use the :fast_track_to_railway
shortcut in options
to wire the two fast track termini to their railway friends.
class MemosController < ApplicationController
# ...
endpoint do
options do
{
# ...
fast_track_to_railway: true,
}
end
end
# ...
end
This will result in a flow diagram similar to this.
Here, the distinction between failure
and fail_fast
gets lost after the endpoint has been run. In most scenarios, this is desired, as this mimics querying the result object via result.success?
.
In some cases you might want to deal with a fail_fast
or pass_fast
termination of your operation. Simply override the option when defining the specific endpoint.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Update,
fast_track_to_railway: false
# ...
end
Your endpoint now needs to define those two additional termini, so they can be properly connected.
You can define a default block for the wiring of the domain operation.
class MemosController < ApplicationController
# ...
endpoint do
options do
{
protocol_block: -> do
{Output(:fail_fast) => Track(:not_found)}
end,
}
end
end
# ...
end
The :protocol_block
option is used when compiling an endpoint, you don’t need to specify the custom wiring anymore.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Update, protocol: MyProtocol # {fail_fast} => {not_found}
# ...
end
It’s even possible to have a dynamic :protocol_block
. The block is executed in the context of the domain operation.
class MemosController < ApplicationController
# ...
endpoint do
options do
{
protocol_block: -> do
if to_h[:outputs].find { |output| output.semantic == :not_found }
{Output(:not_found) => End(:not_found)}
else
{} # no custom wiring.
end
end,
}
end
end
# ...
end
In this example, the block is evaluated in context of Memo::Operation::Update
. Within this block, you may use the Introspect
API or any method of the domain operation. Here, to_h
translates to Memo::Operation::Update.to_h
, allowing you to add custom wiring as it applies.