Coach, a few years on
Last editedOct 2020
Here at GoCardless we’ve been using Coach to power our routes for about 5 years now, and we thought it could be interesting to share a few things we’ve learned.
We have just over 100 middlewares, 10 for Authentication, 15 for providing and manipulating models and collections, about 40 general utilities and the rest are used in a specific part of our API (e.g. the oauth flow).
Middlewares are great
The Coach pattern of middlewares - i.e. a bit of code shared between routes that has access to a request context - is still a strong one. You see the same in NodeJS with Express, Go http.Handler, and many others. It makes our routes easy to read, and keeps ‘route-like’ code nicely separate from the rest of the application. A simple example:
class APIVersion < Coach::Middleware
provides :api_version
def call
if api_version.blank?
return Renderer.error(Constants::Errors::MISSING_VERSION_HEADER,
request: request)
end
unless api_version.match?(Constants::Versions::FORMAT)
return Renderer.error(
Constants::Errors::VERSION_HEADER_BADLY_FORMATTED,
request: request,
)
end
unless api_version.in?(Constants::Versions::SUPPORTED_RELEASES)
return Renderer.error(Constants::Errors::VERSION_NOT_FOUND,
request: request)
end
provide(api_version: api_version)
next_middleware.call
end
private
def api_version
@api_version ||= request.headers[Constants::Versions::HEADER]
end
end
We can also use config to make really readable statements by naming our config variables well. There are lots of interesting use cases, my favourite is how we manage Collections
.
Many of our APIs return an ActiveRecord collection, and so to help us we’ve built a series of middlewares which allow you to manipulate a collection. Each middleware requires and provides the coach variable collection
to ensure you can pick and choose different combinations. Some examples are:
Collection::Provide
which receives a Proc to instantiate a collection. It is given as a proc so that, when loading the class, no connection to Postgres is accidentally established (which would happen if we called a scope inside the collection definition).
module Collection
class Provide < Coach::Middleware
provides :collection
def call
provide(collection: config.fetch(:collection).call)
next_middleware.call
end
end
end
Filter
takes a config param :using
which defines a filter function.
class Filter < Coach::Middleware
requires :collection
provides :collection
def call
provide(collection: filter.new(collection, request.query_string).apply)
next_middleware.call
end
def filter
config.fetch(:using)
end
end
GC has a multi-tenant system, so we use ByOrg
to manage that in a reusable way. The ActiveRecord models implement a for_organisation_ids
scope to make this work:
class ByOrg < Coach::Middleware
requires :organisation, :collection, :gocardless_admin?
provides :collection
def call
if unscoped_access?
provide(collection: collection.all)
else
provide(collection: collection.for_organisation_ids(organisation.id))
end
next_middleware.call
end
private
def unscoped_access?
gocardless_admin? && config.fetch(:unscoped_global_access, false)
end
end
Overall Usage:
uses Middleware::Collection::Provide, collection: -> { MyModel.active }
uses Middleware::Collection::ByOrg
uses Middleware::Collection::Filter, using: MyModelRouteFilter
uses Middleware::Collection::IncludeAssociations, map: { "parent" => :parent }
# this provides :paginated_collection and should always be called last
uses Middleware::Collection::Paginate
There is such a thing as too many Middlewares
“Middlewares are like a tool box, but if there are too many tools in the box, you’ll never find the right one”. This results in routes that are missing middlewares they probably should have, and duplicated logic across middlewares. We have been guilty of this at times, for a couple of primary reasons:
As with other parts of large code projects, it’s not always easy to identify when code within a middleware becomes obsolete (e.g. a parameter or code path that is never used). The uses
pattern means that it can be even harder for tools like debride
to help you out with this, so keeping it well maintained requires serious dedication to the cause.
It can be tempting to write one-use middleware if you want to leverage other middlewares later in the chain. (e.g. something that does something complex and specific before you run your standard Paginate middleware). I think there are a couple of potential solutions here: either have a standard way of managing one-use middlewares (maybe in their own namespace), or build tooling that allows you to pass a service object into something that dynamically creates your middleware for you (which while clever, may confuse both your colleagues and coach-cli
) - see the Provide
middleware at the end of this article for some inspiration.
Nested Middlewares are dangerous
Middlewares can, of course, use
other middleware. This is a powerful pattern, but should be treated with caution. It is not hard to generate a middleware tree which no developer can keep in their head, and thus can cause mistakes (just like anything that’s obfuscated behind many classes / files). I think the key to this one is having clear middleware names, and not nesting middlewares more than 2, or if you really have to 3 deep. If you’re using lots of very small Middlewares (e.g. to parse particular http headers) - perhaps look at whether a Utility function might serve you better. I’d also avoid class inheritance if you don’t really need it - it’s possible to write some quite confusing code this way.
Having said that, there are definitely some situations where it is the right call to nest middlewares. When you have lots of middlewares which you want to call on pretty much all your routes, you have a trade-off between being really declarative (the route specifying all the middlewares) and ultra-safe (developers can’t accidentally forget one of your [10] standard middlewares because they’re rolled into one, and you have a cop that makes sure it’s present on all routes except a specified whitelist).
class Authenticate < Coach::Middleware
uses Middleware::Auth::GetAccessToken
uses Middleware::Auth::RecordUserIpAddress
uses Middleware::Auth::RestrictApiUsage
uses Middleware::Auth::SetCookies
end
class GetAccessToken < Coach::Middleware
uses Middleware::Auth::ParseAuthorizationHeaderBearer
requires :api_version
provides :access_token, :organisation, :user_id, :partner_app, :api_scope
(...)
end
Control flow with middlewares is hard
This might seem obvious, but I've been surprised how often I've hit this specific obstacle - you can't conditionally call a middleware. It's unlike most other coding, where if
blocks are a staple, so requires you to think in a particular way that may or may not come naturally. This can result in some strange patterns, including middleware that simply provide
default versions of another middleware's provides
to enable future middlewares to run. We sometimes get around this particular issue by pulling something out of Coach's internal @context
object directly (e.g. user: @context.fetch(:user, nil)
), which of course removes your contract with Coach that the context variable you require
will always be present. There's not an easy solution here, but I suppose one angle is that if you really need lots of conditionally called code in your route, middlewares might not be the right pattern for you.
Another issue you may encounter is that you can’t pass config
from a top level middleware into nested Middlewares. When you think about what Coach is actually doing under the hood, it is understandable; config is not designed to be dynamic, and when you use
a Middleware it is going to run before its caller is invoked. That does mean as a developer it can be quite un-intuitive to use (when you’re used to calling plain old functions anyway).
You can use middlewares to run code after your route has executed
Most use cases for middlewares are top-to-bottom as the request is processed - e.g. check the headers, authenticate the request, grab and manipulate the collection. However, there are some cases where it’s useful to run code after the route has returned its response. An obvious example is logging the API response (e.g. for status code and timing graphs) - you can also use it to validate the response is of an expected shape (i.e. conforms to your schema). An example:
class TrackMetrics < Coach::Middleware
def call
begin
status, = result = next_middleware.call
METRIC.increment(labels: { status: status, use_case: use_case })
rescue StandardError
METRIC.increment(labels: { status: 500, use_case: use_case })
raise
end
result
end
end
You can hack Coach
A final gift. This is a controversial file at GC - some people think it’s brilliant, others want to burn it to the ground.
module Middleware
# This object takes a map of properties and functions to invoke in the request context,
# and builds an anonymous middleware which actually "provides" the results of these
# blocks to downstream dependencies.
#
# The main objective is to be able to, within a given route, dynamically define the
# properties that you want to be passed around within the request chain.
#
# @param config [Hash{Symbol => #call}] a list of properties to be passed into the chain
#
# @example Constructing a collection of models
# uses Middleware::Provide.new(collection: -> { Customer.all })
#
# @example Plucking a key from the parameters
# uses Middleware::Provide.new(limit: ->(req) { req.params[:limit] })
#
# @example Assigning multiple properties
# uses Middleware::Provide.new(foo: ->(req) { req.params[:foo] },
# bar: -> { false })
#
# @example Using a method reference
# uses Middleware::Provide.new(collection: method(:base_collection))
module Provide
# ^ this is a module because .new returns an instance of an anonymous class
def self.new(config = {})
config.each_value do |func|
raise ArgumentError, <<~MSG if func.arity.negative? || func.arity > 1
Callable objects given to Middleware::Provide must accept either a single
`request` argument, or no arguments at all, e.g.:
uses Middleware::Provide.new(foo: ->(req) { req.params[:foo] })
or
uses Middleware::Provide.new(foo: -> { :bar })
MSG
end
Class.new(Coach::Middleware) do
provides(*config.keys)
define_method(:call) do
provide(config.
transform_values { |f| f.arity.zero? ? f.call : f.call(request) })
next_middleware.call
end
define_singleton_method(:name) do
"Middleware::Provide#{config.keys.inspect}"
end
end
end
end
end