The stable endpoint
gem will be released November 2024.
Whenever you need an operation “around” your actual business operation, to add authentication, policy checks or more steps before or after your domain code, you want to start using endpoints.
Instead of you manually calling operations, endpoints are planned to become the canonical way of invoking operations in controllers, jobs and tests.
Operation.call_with_public_method
to add aliases or tweak standard variables in the ctx. This now becomes nothing but another step in the endpoint, before the domain operation.An 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.
Endpoints define a 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 is to have specific handlers for all outcomes that you write once, while allowing to override specifc cases. In other words, not_found
renders a 404 error automatically, but you deal with success
manually.
Endpoints and the matcher interface can be used anywhere, in controllers, tests, background jobs, or wherever you need to invoke an operation. This example showcases a typical Rails controllers.
class MemosController < ApplicationController
endpoint Memo::Operation::Create # define endpoint.
def create
invoke Memo::Operation::Create do # call the endpoint, use matchers:
success { |ctx, model:, **| redirect_to memo_path(id: model.id) }
# failure is inherited
# not_found is inherited
end
end
end
Endpoints (currently!) have to be defined on the controller class level using the ::endpoint
method. Additional options allow configuring how the operation is wired in the endpoint.
Actually calling the endpoint (along with your operation) works via the #invoke
instance method, which you typically place in a controller action.
The block handed to #invoke
is where it gets interesting. The matcher interface allows to provide code blocks to handle different outcomes. However, the beautiful part is that you can inherit generic matchers from the ApplicationController
(or wherever you inherit from).
class ApplicationController < ActionController::Base
include Trailblazer::Endpoint::Controller.module
# ...
endpoint do
# ...
default_matcher do
{
failure: ->(ctx, **) { head 401 }, # handles {failure} outcome.
not_found: ->(ctx, params:, **) do
render html: "ID #{params[:id]} not found.", status: 404
end
}
end
end
end
Here, using default_block
, generic matchers can be defined, saving you tons of code in the controller, and making nested rescue
shenanigans in your application controller unnecessary.
The Controller
module from the endpoint
gem ships with several configuration directives, like what to pass into the ctx
, default matchers, or whether or not fast-track termini should be wired to the standard railway termini.
The endpoint
gem can be used in frameworks other than Rails (for instance, Grape). Being the mainstream choice of many, these docs focus on Rails.
To use endpoint’s runtime tool #invoke
and its configuration directives, include the Controller
module in your ApplicationController
.
class ApplicationController < ActionController::Base
include Trailblazer::Endpoint::Controller.module
Your controller and all subclasses are now ready to configure and run endpoints. Move all generic, reusable configuration to the uppermost controller - usually that’d be the ApplicationController
.
The easiest way to configure is using the ::endpoint
block DSL.
class ApplicationController < ActionController::Base
include Trailblazer::Endpoint::Controller.module
endpoint do
options do
# ...
end
ctx do
# ...
end
default_matcher do
# ...
end
end
Per default, all configuration options are inherited, allowing you very lean concrete controllers that only override specific directives.
class MemosController < ApplicationController
(We’re planning to allow merging and overriding for each directive.)
Setting default values for endpoints on the controller level can be done in two ways.
::endpoint
block. This section explains each option. Note that ::endpoint
can also be used without a block, to define individual endpoints.Use the ::endpoint
block DSL on the controller class level to configure how endpoints are built and run.
class ApplicationController < ActionController::Base
# ...
endpoint do
options do
{
protocol: Endpoint::Protocol,
# connect fast track outputs to success/failure:
fast_track_to_railway: true,
}
end
ctx do |controller:, **| # this block is executed in controller instance context.
{
params: controller.params,
current_user: controller.current_user,
}
end
flow_options do |controller:, activity:, **|
{
context_options: {
aliases: {"contract.default": :contract},
container_class: Trailblazer::Context::Container::WithAliases,
}
}
end
default_matcher do
{
failure: ->(ctx, **) { head 401 }, # handles {failure} outcome.
not_found: ->(ctx, params:, **) do
render html: "ID #{params[:id]} not found.", status: 404
end
}
end
end
# ...
end
Available options are explained in the following sections.
You can keep controller actions very lean by defining generic handlers once, on the ApplicationController
level using the default_matcher
block.
endpoint do
# ...
default_matcher do
{
failure: ->(ctx, **) { head 401 }, # handles {failure} outcome.
not_found: ->(ctx, params:, **) do
render html: "ID #{params[:id]} not found.", status: 404
end
}
end
# ...
end
The returned hash may provide matchers for any possible outcome.
The blocks receive the ctx
after your endpoint is run, along with keyword arguments, exactly what you’re used to from operation steps. Note that the block is executed in the controller context, allowing to use its API, e.g. #render
or #head
.
You can override specific handlers in controller actions.
The compilation of your controller’s endpoints can be configured using options
.
endpoint do
# ...
options do
{
protocol: Endpoint::Protocol,
# connect fast track outputs to success/failure:
fast_track_to_railway: true,
}
end
# ...
end
Make sure you provide a :protocol
as this is the only requirement in this directive. # TODO: link to protocol.
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.
endpoint do
# ...
options do
{
# ...
# connect fast track outputs to success/failure:
fast_track_to_railway: true,
}
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?
.
However, in some cases, you might want to deal with a fail_fast
or pass_fast
termination of your operation. Simply override it when defining the specific endpoint.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Create, fast_track_to_railway: false
# ...
end
Your endpoint now needs to define those two additional termini, so they can be properly connected.
Use the ctx
block to define what ctx variables to pass into the endpoint (and operation) invocation by default.
endpoint do
# ...
ctx do |controller:, **| # this block is executed in controller instance context.
{
params: controller.params,
current_user: controller.current_user,
}
end
# ...
end
The block is executed in controller instance context and allows you to access the environment you have within a controller action, e.g. params
or request
.
You can override this directive per controller (on the class level), or add variables in the controller action via #invoke
.
It is possible to set a default wiring for your domain operations in their endpoint using the :protocol_block
. The following example connects the not_found
terminus to the endpoint’s not_found
track. As this is not a standard railway/fast-track terminus, it’s not wired automatically.
class ApplicationController < ActionController::Base
# ...
options do
{
# ...
fast_track_to_railway: true, # FIXME: test this!
# default wiring, applied to all endpoints:
protocol_block: -> do
{Output(:not_found) => End(:not_found)}
end
}
end
# ...
end
The block simply needs to return a hash that contains Wiring API instructions. Here is the resulting diagram of that endpoint part.
Keep in mind, though, that this wiring is applied to all operations in this controller. If a particular operation doesn’t expose a not_found
output, this will raise an exception!
`No "not_found" output found for :domain_activity`...
In this case, override the wiring for the respective endpoint.
Alternatively, you may use conditional code leveraging the operation’s introspection API. The protocol_block
is executed in the respective operation context each time you’re adding a concrete endpoint.
class ApplicationController < ActionController::Base
# ...
endpoint do
options do
{
# ...
# default wiring, applied to all endpoints:
protocol_block: -> do
if to_h[:outputs].find { |output| output.semantic == :not_found }
{Output(:not_found) => End(:not_found)}
else
{}
end
end
}
end
# ...
end
# ...
end
You can set flow_options
that are injected into the endpoint (or operation) invocation using the same-named block. This is where you would set ctx aliases, for instance.
endpoint do
# ...
flow_options do |controller:, activity:, **|
{
context_options: {
aliases: {"contract.default": :contract},
container_class: Trailblazer::Context::Container::WithAliases,
}
}
end
# ...
end
While the block version of ::endpoint
allows to configure the build process and some runtime variables, most of the time you will be using this method to define endpoints per controller action in a concrete controller.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Create # define endpoint.
# ...
end
This will, at compile time, build and store a dedicated endpoint for your Memo::Operation::Create
operation. You can now use that endpoint in your controller actions using #invoke
.
Currently, this is a required step, as compiling an endpoint takes time and technically only has to be done once - at compile time.
However, we might come up with a solution that makes defining endpoints redundant in the future.
You may add arbitrary wiring for your operation when defining the concrete endpoint. This is done by passing a block after the endpoint’s constant.
class MemosController < ApplicationController
# ...
endpoint Memo::Operation::Update do
{
Output(:not_found) => End(:failure)
}
end
# ...
end
This wiring block will override any :protocol_block
in an inherited controller.
Keep in mind that you can also “turn off” the wiring from a super-controller by returning an empty hash.
endpoint Memo::Operation::Create do
{} # override ApplicationController's wiring.
end
If you need one distinct operation in several endpoints, pass a name instead of the class.
class MemosController < ApplicationController
# ...
endpoint "create", domain_activity: Memo::Operation::Create
endpoint "create/admin",
domain_activity: Memo::Operation::Create,
protocol: Protocol::Admin
# ...
end
The actual operation to be embedded in the endpoint is specified via :domain_activity
. Note that you may pass additional build options here.
In the controller action, you now need to pass the name to #invoke
.
class MemosController < ApplicationController
# ...
def create
invoke "create" do # endpoint name
# ...
end
end
# ...
end
The real fun starts when using endpoints in a controller action. This is when the endpoint and your business logic is executed and the respective matcher is run.
You can invoke the controller’s endpoints using #invoke
. Just pass the endpoint’s name, which is usually the class constant.
class MemosController < ApplicationController
# ...
def create
invoke Memo::Operation::Create do # call the endpoint, use matchers:
success { |ctx, model:, **| redirect_to memo_path(id: model.id) }
# failure is inherited
# not_found is inherited
end
end
# ...
end
This will run the endpoint, along with your embedded domain operation. Using the matcher block, you can override specific handlers ad-hoc, in case the inherited default matchers don’t suit the controller action.
Keep in mind that, per default, only the variables configured in the ctx {}
block are passed into the endpoint, and the operation.
endpoint do
# ...
ctx do |controller:, **| # this block is executed in controller instance context.
{
params: controller.params,
current_user: controller.current_user,
}
end
# ...
end
In the operation, you will only see the variables configured above.
module Memo::Operation
class Update < Trailblazer::Operation
step :find_model
# ...
def find_model(ctx, **)
p ctx.keys.inspect # => [:params, :current_user]
end
end
end
If you need additional variables, simply pass them to #invoke
.
class MemosController < ApplicationController
# ...
def update_with_runtime_variables
invoke Memo::Operation::Update, storage: MyBucket do
# ...
end
end
# ...
end
Your operation can now see the :storage
variable, too.
def find_model(ctx, **)
p ctx.keys.inspect # => [:params, :current_user, :storage]
end
Variables handed to #invoke
will override default variables from the ctx {}
block.
Inheritance