Chapters

2.1 Endpoint Documentation

trailblazer-endpoint 0.1.0

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.

Canonical invoke

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.

  1. You don’t have to maintain a monkey-patch.
  2. Operations and activities, in controllers, tests, tasks, or on the CLI, are all invoked with the same configuration.
  3. You may use a matcher-like block to handle the outcome directly, if needed.
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.

Configuration

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.

Controller

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.

Installation

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.

Invoke

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.

Configuration

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.

Configuration ctx

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.

Configuration default_matcher

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.

Configuration invoke

TODO

Configuration Inheritance

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.

Protocol

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.

Overview

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.

Overview Protocol

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.

Overview Defining

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.

Overview Invocation

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.

Layout

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.

Layout Terminus

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.

  1. success obviously indicates everything went fine.
  2. failure informs the caller of a validation error.
  3. not_found could be terminated on when a model wasn’t found.
  4. If the current_user couldn’t be computed, due to invalid credentials, the not_authenticated terminus is hit.
  5. Any kind of policy breach could be routed to not_authorized.

Layout Handlers

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.

Layout Wiring

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.

Configuration

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.

Configuration options

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.

Configuration fast_track_to_railway

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.

Configuration protocol_block

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.