Endpoint

  • Last updated 29 Oct 22

The endpoint gem is the missing link between your routing framework and your business code. It acts like a mix of before_filters and “responder” that handles authentication and authorization, invoking your actual logic, and rendering a response.

These docs, as of early December 2020, are still work-in-progress, and so is the endpoint gem. Your input for best practices is highly appreciated!

We’re pretty confident that by the end of the year endpoint is released and the API docs are streamlined.

Overview

An endpoint links your routing with your business code. The idea is that your controllers are pure HTTP routers, calling the respective endpoint for each action. From there, the endpoint takes over, handles authentication, policies, executing the domain code, interpreting the result, and providing hooks to render a response.

Instead of dealing with a mix of before_filters, Rack-middlewares, controller code and callbacks, an endpoint is just another activity and allows to be customized with the well-established Trailblazer mechanics.

The abstract cave painting beautifully illustrates the concept of an endpoint.

  • Instead of invoking your domain operation yourself (which would be running Song::Operation::Create here), the endpoints takes care of that. You only configure and run the endpoint.
  • The endpoint runs auth*-related logic before and response-specific code after the operation, depending on the outcome of the former code. Flow-control in the endpoint is implemented identically to what happens in activities and operations: an endpoint is just another activity using the Wiring API to insert steps and connect outputs.
  • For pluggability, an endpoint is split into a Protocol and an Adapter part. This separates generic logic from environment-dependent code.
  • You may use the wiring mechanics to customize endpoint, adapter, protocol, remove or change steps, rewire outputs, inherit from base endpoints, and so on.
  • All Trailblazer developer niceties are available: tracing and debugging endpoints via #wtf? and friends helps understanding flow and behavior.

Example

In a Rails controller, a controller action could look as follows.

class DiagramsController < ApplicationController
  endpoint Diagram::Operation::Create, [:is_logged_in?, :can_add_diagram?]

  def create
    endpoint Diagram::Operation::Create do |ctx, **|
      redirect_to diagram_path(ctx[:diagram].id)
    end.Or do |ctx, **|
      render :form
    end
  end
end

While routing and redirecting/rendering still happens in Rails, all remaining steps are handled in the endpoint.

An API controller action, where the rendering is done generically, could look much simpler.

class API::V1::DiagramsController < ApplicationController
  endpoint Diagram::Operation::Create, [:is_logged_in?, :can_add_diagram?]

  def create
    endpoint Diagram::Operation::Create
  end
end

Endpoints are easily customized but their main intent is to reduce fuzzy controller code and providing best practices for both HTML-rendering controllers and APIs.

Endpoint

Each controller action maintains its very own endpoint. The endpoint contains the actual business operation to be executed.

An endpoint is separated into two parts: protocol and adapter.

The protocol is where authentication, policy checks, and eventually your domain logic happen. All termini of the protocol’s activity are standardized end events - that’s how protocol and adapter communicate.

Note how a handful of default steps lead into six standardized termini, allowing to plug protocols into different adapters. Imagine replacing your self-written API adapter with a canonical JSON-API adapter, for example.

The default steps of the protocol handle the following aspects.

  • AUTHENTICATION The authenticate step is specific to the environment. For instance, it might deserialize a user from a web cookie, or parse an XML header for authentication detail. The outcome is communicated through its well-defined terminus not_authenticated. It is up to you to implement this step.
  • AUTHORIZATION Per default, there’s a policy step to decide whether or not the domain operation should be invoked. Should the check fail the protocol will end on not_authorized. It is up to you to replace, implement or remove this step.
  • DOMAIN ACTIVITY Once the above steps have been executed successfully, your actual business code is invoked. This could be an operation, a workflow, or hand-baked Ruby code completely unrelated to Trailblazer.

Four more termini implement the following concepts.

  • success indicates a successful run of the domain activity.
  • failure is interpreted as “invalid data”. Most operations end on failure if something went “wrong”, such as a failed validation.
  • not_found is reached when the domain operation (or additional steps in the protocol) couldn’t find a particular object. Most “legacy” operations don’t have this output, yet. However, the Model() macro now supports this terminus. Alternatively, you could add a decider step after your domain activity that connects to the not_found terminus if ctx[:model] is empty.
  • invalid_data is an experimental terminus that is designed to represent a validation failure more explicitly. Ignore it for now.

It is important to understand that the protocol doesn’t interact with the environment: while it handles all the “boring” tasks like auth* and running the actual domain activity, it does not react to that but merely communicates the outcome via one of the well-defined termini.

In turn, the adapter knows nothing about what happened in the protocol. Its job is to prepare everything for the response, which usually implies setting HTTP headers, a status and rendering some kind of document.

Structurally, the adapter activity contains the protocol as just another step. The protocol’s termini are wired to the handler paths in the adapter (depicted above as dotted lines). As visible, the wiring is completely customizable using the TRB wiring API. For example, failure and invalid_data are both wired to the same handler path.

The three end events not_found, not_authenticated and not_authorized are all running through the same path, ending in the fail_fast terminus. This path is called protocol_failure and implies a non-business problem, like a failed authentication.

Business failures such as a invalid contract validation will usually end up on the failure terminus.

Both the Web and the API adapter will now run, and the frontend code then interprets the outcome and runs your configured block (e.g. render or redirect_to).

Currently, we have very simple adapters that don’t do much, as we’re still figuring out the best practices here.

Configuration

In Rails, the endpoint layer is introduced by including a customized module in a controller. You may do so by using Controller::module.

class ApplicationController::Api < ApplicationController
  include Trailblazer::Endpoint::Controller.module(api: true, application_controller: true)

The options are discussed in the respective API and Web sections. When including, a bunch of configuration directives are set up, and a handful of class and instance methods are added to the controller.

Controller-level Setup

Configuring endpoints for your application involves three steps.

  • Defining application-wide and/or controller-wide runtime options by setting directives.
  • Setting application or controller-wide default values for building endpoints at compile-time using the “hash-form” of ::endpoint.
  • Fine-tuning each controller action endpoint using the “targeted” endpoint Song::Operation::Create and passing specific options.

Those options will be combined at compile-time to build a specific endpoint activity for each controller action, where the “targeted” options (3.) override default settings from the “hash-form” (2.).

It is a good strategy to configure as much as possible on the controller-level and customize per specific endpoint. Use the “hash-form” of endpoint to configure default options.

For example, in a set of web controllers, most endpoints will reuse the same protocol and adapter. You can specify default values on the (application) controller level, here, on ApplicationController::Web.

# app/controllers/application_controller/api.rb
endpoint protocol: Protocol, adapter: Adapter::Representable do
  # {Output(:not_found) => Track(:not_found)}
  {}
end

The “hash-form” ::endpoint method accepts the following options.

  • :protocol
  • :adapter
  • The “protocol block” passed as a block to ::endpoint. This block is executed in the protocol context and allows adding, changing and removing steps using the TRB wiring API. Currently, you need to return a hash from it. (FIXME)
  • :find_process_model to activate process model finding.
  • :serialize and :deserialize to active encrypted suspend/resume session handling.
  • :deserialize_process_model_id_from_resume_data to automatically extract the process model’s ID from the resume data. (experimental)

Keep in mind that those options can be overridden using the “targeted” ::endpoint method.

Every action needs to have its very own endpoint set up. This is usually done one the class level with the ::endpoint method.

class SongsController < ApplicationController::Api
  endpoint Song::Operation::Create
  endpoint Song::Operation::Show do
    {Output(:not_found) => Track(:not_found)}  # add additional wiring to {domain_activity}
  end

It’s sufficient to simply pass the operation constant to ::endpoint to make Trailblazer set up an endpoint that contains the specified operation as its domain activity. All other values are copied over from the generic setup.

Note that you can override the protocol block per action/endpoint.

It’s possible to use an arbitrary alias for the endpoint if you provide the :domain_activity option.

endpoint :create, domain_activity: Song::Operation::Create

You can then invoke the endpoint by providing the alias.

def create
  endpoint :create
end

This is helpful for triggering events when using workflows within the endpoint. (We will document that soon!)

todo domain_ctx_filter, :current_user

As everything in TRB, an endpoint is invoked top-to-bottom. Any dependency required inside, for instance the current_user in the domain activity, needs to be “thrown into” the invocation. Just like you’re used to when calling operations manually.

Injecting dependencies is done using directives. Those are Ruby snippets that provide variables.

  def self.options_for_domain_ctx(ctx, controller:, **) # TODO: move to ApplicationController
    {
      params: controller.params,
    }
  end

  directive :options_for_domain_ctx,    method(:options_for_domain_ctx)

By registering directives using the same-named method, Trailblazer knows that you want to add variables to a well-defined configuration hash. The directives are executed at certain hooks in endpoint and hopefully provide the desired data.

The endpoint gem uses four directives.

  • options_for_endpoint is data passed directly to the endpoint activity. For instance, if you need to parse an XML body for authentication, or inspect a JWT, you have to make sure that at least the request object is available in this hash.
  • options_for_domain_ctx represents the “hash” passed to the domain activity. This is where params and such is passed.
  • options_for_block_options are three blocks defining the behavior for the three different outcomes. Hence, the keys are :success_block, :failure_block and :protocol_failure_block.
  • options_for_flow_options are library-specific options. Here, you could set context_options to alias keys in the ctx object, activate your own tracing, etc.

Please refer to the API and Web sections to see directives in action.

TODO note: we will shortly document how directives work generically as it’s a mechanism independent from endpoint.

Runtime

To run an endpoint in a controller action, use the #endpoint method. Its interface varies depending on if you use the API or the DSL version.

def create
  endpoint Song::Operation::Create do |ctx, model:, **|
    redirect_to song_path(model.id)
  end
end

The particular endpoint is referenced by passing the alias to the #endpoint method, which is usually the constant of the operation.

Any options passed to the method will be available in the endpoint_ctx.

def create
  endpoint Song::Operation::Create, session: {user_id: 2} do |ctx, current_user:, model:, **|
    render html: cell(Song::Cell::Create, model, current_user: current_user)
  end
end

If you want to override the domain_ctx, the :options_for_domain_ctx option is your friend.

def create
  endpoint Song::Operation::Create, options_for_domain_ctx: {params: {id: 999}} do |ctx, model:, **|
    render html: cell(Song::Cell::Create, model)
  end
end

The options_for_domain_ctx directive will not be used if you provide the option manually.

API Interface

If a controller exposes a generic behavior, the API interface is your pick. While you can still override specific behavior, it abstracts best practices for document APIs.

The API interface is designed to generically render a document response (such as XML or JSON) along with a response status. It does so by placing handler and render steps onto the three outcome tracks for success, failure and protocol_failure in an API-specific Adapter.

In Adapter::API there’s one track for a successful domain execution, one for failure or invalid_data and one protocol failure track that handles not_found, not_authorized and not_authenticated. The idea is to render a generic error with HTTP status code for the latter three cases, hence all three outcomes lead into the same track.

Application Controller

It is a good practice to install an ApplicationController::Api controller in your app to inherit from.

class ApplicationController::Api < ApplicationController
  include Trailblazer::Endpoint::Controller.module(api: true, application_controller: true)

Introducing all necessary class and runtime methods, every controller inheriting from Api is ready to be used as an endpoint controller for a document API.

Please include the Endpoint::Controller module only once per inheritance tree.

Action

Since the response behavior in APIs is very universal, controllers usually look quite simple.

module Api
  module V1
    class SongsController < ApplicationController::Api
      endpoint Song::Operation::Create
      endpoint Song::Operation::Show do
        {Output(:not_found) => Track(:not_found)}  # add additional wiring to {domain_activity}
      end

      def create
        endpoint Song::Operation::Create, representer_class: Song::Representer
      end

      # ...
    end
  end
end

Note that the concrete controllers are derived from ApplicationController::Api.

On the class level, each endpoint is configured using the ::endpoint method. The only required option is the operation you intend to run (also known as domain_activity). Optionally, you might add additional wiring using the protocol block, such as adding a not_found output. (FIXME: explain later)

In the actual action you can now run the endpoint and your domain operation using the #endpoint instance method. Additional options may be passed and will be available in the endpoint_ctx.

Endpoints should be configured on the class level. This allows compiling each activity when your code is loaded, which results in a much faster app performance than computing the endpoint at runtime.

Configuration

A handful of options need to be configured using Trailblazer’s directive mechanism.

Universal behavior is usually configured on the app-level. In Rails, this would be the ApplicationController or a generic subclass such as ApplicationController::Api.

class ApplicationController::Api < ApplicationController
  include Trailblazer::Endpoint::Controller.module(api: true, application_controller: true)

  def self.options_for_block_options(ctx, controller:, **)
    response_block = ->(ctx, endpoint_ctx:, **) do
      controller.render json: endpoint_ctx[:representer], status: endpoint_ctx[:status]
    end

    {
      success_block:          response_block,
      failure_block:          response_block,
      protocol_failure_block: response_block
    }
  end

  directive :options_for_block_options, method(:options_for_block_options)

The hash returned by #options_for_block_options configures the generic behavior for the three outcomes success, failure and protocol_failure.

You have access to the currently executed controller instance and hence can use all of Rails’ controller API, in our case, render a JSON document and set a HTTP response status. Since all three outcomes do the same, this is very generic configuration. Nevertheless, here is the place to add additional headers such as Authorization.

Both the :representer and the :status variables are set during the endpoint’s invocation. We will discuss this shortly.

Last, you need to use the ::directive method to register your configuration. The :options_for_block_options directive is used internally after running the endpoint and before rendering the response.

Per default, the three blocks to render the response are configured via the :options_for_block_options directive. You may override them via #endpoint.

def show
  endpoint Song::Operation::Show, representer_class: Song::Representer,
    protocol_failure_block: ->(ctx, endpoint_ctx:, **) { head endpoint_ctx[:status] + 1 }
end

This is handy to customize behavior in a particular action, or for debugging.

As the endpoint needs data to perform authentication, authorization and eventually the rendering, a few more options are set using the canonical options_for_endpoint directive.

  def self.options_for_endpoint(ctx, controller:, **)
    {
      request: controller.request,
      errors_representer_class: App::Api::V1::Representer::Errors,
      errors: Trailblazer::Endpoint::Adapter::API::Errors.new,
    }
  end

  directive :options_for_endpoint, method(:options_for_endpoint)

The three variables :request, :errors and :errors_representer will now be injected into the endpoint’s ctx. Note that these are specific to our authentication and our very own Adapter implementation. In your project, you might not need representers or an application Errors object.

In order to inject data into your domain operation (e.g. Song::Operation::Create), use the :options_for_domain_ctx directive. It can be easily overridden at run-time in the controller action if you need to tweak it specifically (FIXME: add link).

  def self.options_for_domain_ctx(ctx, controller:, **) # TODO: move to ApplicationController
    {
      params: controller.params,
    }
  end

  directive :options_for_domain_ctx,    method(:options_for_domain_ctx)

The domain operation can now access ctx[:params].

Protocol

For an API-specific protocol, in most cases a password or JWT-based authentication will be installed. Authorization, or a policy deciding whether or not this action is legit, can be handled with your own logic or entirely removed and done in the operation itself.

Both authorization and policy are discussed in the tutorial, simple versions are implemented in the gem test suite.

Whatever your auth* logic does, it should set a ctx[:current_user] that will then get passed into your business operation.

class Protocol < Trailblazer::Endpoint::Protocol
  step Auth::Operation::Policy, inherit: true, id: :policy, replace: :policy
  step Subprocess(Auth::Operation::Authenticate), inherit: true, id: :authenticate, replace: :authenticate
end

Derive your API protocol from Trailblazer::Endpoint::Protocol and override the steps you want to customize using the :inherit option, so that the original step’s wiring stays in place.

Adapter

It is important to understand that the Adapter::API shipped with endpoint only sets the HTTP status according to the outcome. In order to render a document, additional steps have to be added to the adapter subclass.

module Adapter
  class Representable < Trailblazer::Endpoint::Adapter::API
    step :render # added before End.success
    step :render_errors, after: :_422_status, magnetic_to: :failure, Output(:success) => Track(:failure)
    step :render_errors, after: :protocol_failure, magnetic_to: :fail_fast, Output(:success) => Track(:fail_fast), id: :render_protocol_failure_errors

    def render(ctx, domain_ctx:, representer_class:, **) # this is what usually happens in your {Responder}.
      ctx[:representer] = representer_class.new(domain_ctx[:model] || raise("no model found!"))
    end

    def render_errors(ctx, errors:, errors_representer_class:, **) # TODO: extract with {render}
      ctx[:representer] = errors_representer_class.new(errors)
    end

    Trailblazer::Endpoint::Adapter::API.insert_error_handler_steps!(self)
    include Trailblazer::Endpoint::Adapter::API::Errors::Handlers # handler methods to set an error message.
  end # Representable
end

Those render steps in our example app use two injected variables for rendering.

The :representer_class variable is injected per controller action by passing it to the endpoint helper.

def create
  endpoint Song::Operation::Create, representer_class: Song::Representer
end

Since the error rendering is generic, the :errors_representer_class variable is set controller-wide via options_for_endpoint.

api endpoint

Putting together the Protocol with the specific Adapter along with the Create domain operation will result in a flow as depicted here.

The separation of the Protocol and Adapter allows to use the same business logic in different environments, the Adapter only reacts to well-defined outcomes such as “not found” or “invalid data” and, in an API context, responds with a rendered document and a HTTP status. This behavior could and should be different [in a web UI].

In our example adapter, each _xxx_status method sets a status code in the endpoint’s context. Those steps are shipped with the gem in Adapter::API.

# endpoint/lib/trailblazer/endpoint/adapter.rb
class Trailblazer::Endpoint::Adapter::API < Web
  # ...
  def _422_status(ctx, **)
    ctx[:status] = 422
  end

All handle_* steps are inserted via include Trailblazer::Endpoint::Adapter::API::Errors::Handlers, in their standard version they simply set an error message on the errors object instance.

# endpoint/lib/trailblazer/endpoint/adapter.rb
class Trailblazer::Endpoint::Adapter::API < Web
  # ...
  def handle_invalid_data(ctx, errors:, **)
    errors.message = "The submitted data is invalid."
  end

Feel free to replace, extend or remove these error handlers should you not want to maintain an errors object, for instance.

Check again the above diagram. It is important to understand that only failure and invalid_data are wired to the explicit failure terminus in the adapter. This communicates a domain error, such as a failed validation or an expired account.

All other problems are connected to the protocol_failure step and will end on the fail_fast terminus of the adapter, indicating a protocol error like wrong user credentials, a not-existent record, or missing permissions to perform that very operation.

In the API context, protocol failures often need to be treated differently to domain errors. The first may be communicated to the user with a generic error message and an HTTP status code, whereas the latter often involves rendering the specific validation errors. The design of the Adapter::API in the endpoint gem makes this quite trivial.

Build

Once all parts are written and configured, you need to configure the basic endpoint.

# app/controllers/application_controller/api.rb
endpoint protocol: Protocol, adapter: Adapter::Representable do
  # {Output(:not_found) => Track(:not_found)}
  {}
end

All controllers derived from ApplicationController::Api will inherit those settings.

Test

To see your API endpoints in action, write a test. In the example, we use simple Rails controller tests.

When POSTing a valid document to your Create action, a 200 HTTP is returned along with a JSON response which is the rendered object we just created.

post_json "/v1/songs", {id: 1}, yogi_jwt

assert_response 200
assert_equal "{\"id\":1}", response.body

Note the valid JWT token we pass along in the request. The valid token and the appropriate input data results in the following trace.

The next test covers the not_authenticated case where the JWT was not submitted.

post_json "/v1/songs", {} # no token
assert_response 401
assert_equal "{\"errors\":{\"message\":\"Authentication credentials were not provided or are invalid.\"}}", response.body

In case you’re wondering - the error message is set by the handle_not_authenticated step in the adapter.

Here’s the trace.

As this is a protocol_failure, the endpoint terminates on its fail_fast end.

Web Interface

The Web interface is designed to be used in HTML-rendering controllers that operate web UIs.

While its concepts are very similar to the mechanics found in API endpoints, it focuses on a better action API since web controllers very often need to customize behavior, such as redirecting, rendering pages in a specific fashion or even setting cookie values.

class SongsController < ApplicationController::Web
  # ...
  def create
    endpoint Song::Operation::Create do |ctx, model:, **|
      render html: cell(Song::Cell::Create, model)          # render success cell
    end.Or do |ctx, contract:, **|                          # validation failure
      flash "Errors: #{contract.errors.messages}"
      redirect_to "/my"                                     # manual override
    end
  end
end

Endpoint’s Or-DSL is optimized for web-based controllers.

Adapter

The Trailblazer::Endpoint::Adapter::Web adapter is slightly simpler than the API version.

As oppossed to the Api adapter, there are no handler steps and such installed. Use this adapter if you want to customize the flow or if you don’t need any automatic behavior.

Application Controller

A good practice is to install an ApplicationController::Web controller in your app to inherit from.

class ApplicationController::Web < ApplicationController
  include Trailblazer::Endpoint::Controller.module(dsl: true, application_controller: true)

By providing dsl: true you activate the Or-DSL.

Please include the Endpoint::Controller module only once per inheritance tree.

Configuration

The web interface uses the well-established mechanisms and directives on the controller class level.

Endpoints are defined per action on the controller class.

class SongsController < ApplicationController::Web
  endpoint Song::Operation::Create

You can use the four endpoint directives to configure what data gets passed to the endpoint and your domain activity (or operation).

In order to perform authentication and authorization in the endpoint’s protocol, injecting the session is a good idea.

class ApplicationController::Web < ApplicationController
  include Trailblazer::Endpoint::Controller.module(dsl: true, application_controller: true)

  def self.options_for_endpoint(ctx, controller:, **)
    {
      session: controller.session,
    }
  end

  directive :options_for_endpoint, method(:options_for_endpoint)

This is done by setting a options_for_endpoint directive.

In a web context, the Protocol will usually extract the user from the cookie and perform a policy check based on that user.

class ApplicationController::Web < ApplicationController
# ...
  class Protocol < Trailblazer::Endpoint::Protocol
    # provide method for {step :authenticate}
    def authenticate(ctx, session:, **)
      ctx[:current_user] = User.find_by(id: session[:user_id])
    end

    # provide method for {step :policy}
    def policy(ctx, domain_ctx:, **)
      Policy.(domain_ctx)
    end

    Trailblazer::Endpoint::Protocol::Controller.insert_copy_to_domain_ctx!(self, {:current_user => :current_user})
    Trailblazer::Endpoint::Protocol::Controller.insert_copy_from_domain_ctx!(self, {:model => :process_model})
  end

As always, you don’t have to perform the policy check in the protocol.

With the Protocol class defined, the generic endpoint settings can be configured.

class ApplicationController::Web < ApplicationController
# ...
  endpoint protocol: Protocol, adapter: Trailblazer::Endpoint::Adapter::Web
end

Note that we simply use the Adapter::Web adapter.

The :domain_ctx_filter option will inject the current_user into the domain_ctx so it is accessable from within your domain activity.

Or-DSL

In the controller subclasses you’re now ready to use endpoints and the Or-DSL.

class SongsController < ApplicationController::Web
  endpoint Song::Operation::Create

  def create
    endpoint Song::Operation::Create do |ctx, current_user:, model:, **|
      render html: cell(Song::Cell::Create, model, current_user: current_user)
    end.Or do |ctx, contract:, **| # validation failure
      render html: cell(Song::Cell::New, contract)
    end
  end

The Or block is executed when your endpoint terminates on :failure, which is the case for invalid_data or failure. The endpoint block, as usual, is invoked for a successful outcome.

In both blocks, the domain_ctx is available along with the :endpoint_ctx keyword argument, should you need low-level data.

You can pass #endpoint options at run-time.

def create
  endpoint Song::Operation::Create, session: {user_id: 2} do |ctx, current_user:, model:, **|
    render html: cell(Song::Cell::Create, model, current_user: current_user)
  end
end

Run-time options will be accessable in the endpoint contenxt (not the domain_ctx).

The Or block is only executed for a failure outcome. To catch a protocol failure you can use #protocol_failure.

def create_with_protocol_failure
  endpoint Song::Operation::Create do |ctx, **|
    redirect_to dashboard_path
  end.protocol_failure do |ctx, **|
    render html: "wrong login, app crashed", status: 500
  end
end

The block is only executed for authentication/authorization failures.

Do not return anything from your controller action! This will break the DSL.

def create
  endpoint Song::Operation::Create do |ctx, **|
    # ...
  end.Or do |ctx|
    # ...
  end

  @counter += 1 # this will break!
end

Put any additional code before or into the DSL blocks.