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_filter
s 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_filter
s, 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 terminusnot_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 onnot_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 onfailure
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, theModel()
macro now supports this terminus. Alternatively, you could add a decider step after your domain activity that connects to thenot_found
terminus ifctx[: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
directive
s. - 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 therequest
object is available in this hash.options_for_domain_ctx
represents the “hash” passed to the domain activity. This is whereparams
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 setcontext_options
to alias keys in thectx
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 directive
s 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 success
ful 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 POST
ing 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 success
ful 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.