Chapters

2.1 Endpoint Documentation

The stable endpoint gem will be released November 2024.

trailblazer-endpoint 0.1.0

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.

  • Endpoints provide a simple way of defining possible outcomes of an operation
  • They come with a simple runtime API that allows you to provide matchers for each outcome. For example, you can handle a “model not found” outcome generically, but override how a success outcome is handled.
  • Endpoints are a much better abstraction to customize how your operations are run, and what gets injected. In current projects, many users override 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.

Overview

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.

Example

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.

Controller

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

Inheritance

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.)

Configuration

Setting default values for endpoints on the controller level can be done in two ways.

  1. The recommended way is to use the configuration DSL provided in the ::endpoint block. This section explains each option. Note that ::endpoint can also be used without a block, to define individual endpoints.
  2. Another way would be to override particular methods in the controller. This is undocumented and should only be done if you’re using anything else but Rails.

Configuration Overview

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.

Configuration default_matcher

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.

Configuration options

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.

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.

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.

Configuration ctx

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.

Configuration protocol_block

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

Configuration flow_options

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

Endpoint

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.

Endpoint Wiring

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

Endpoint Name

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

Runtime

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.

Invoke

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.

Invoke Variables

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.

No protocol

Protocol

Inheritance