Alternative controllers with middleware
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
.circleci
docs
lib
spec
.gitignore
.rspec
.rubocop.yml
.rubocop_todo.yml
CHANGELOG.md
Gemfile
LICENSE.txt
README.md
coach.gemspec

README.md

Coach

Gem Version Build Status Code Climate

Coach improves your controller code by encouraging:

  • Modularity - No more tangled before_filter's and interdependent concerns. Build Middleware that does a single job, and does it well.
  • Guarantees - Work with a simple provide/require interface to guarantee that your middlewares load data in the right order when you first boot your app.
  • Testability - Test each middleware in isolation, with effortless mocking of test data and natural RSpec matchers.

For our policy on compatibility with Ruby and Rails versions, see COMPATIBILITY.md.

Installation

To get started, just add Coach to your Gemfile, and then run bundle:

gem 'coach'

Coach works with Ruby versions 2.2 and onwards.

Coach by example

The best way to see the benefits of Coach is with a demonstration.

Mounting an endpoint

class HelloWorld < Coach::Middleware
  def call
    # Middleware return a Rack response
    [ 200, {}, ['hello world'] ]
  end
end

So we've created ourselves a piece of middleware, HelloWorld. As you'd expect, HelloWorld simply outputs the string 'hello world'.

In an example Rails app, called Example, we can mount this route like so...

Example::Application.routes.draw do
  match "/hello_world",
        to: Coach::Handler.new(HelloWorld),
        via: :get
end

Once you've booted Rails locally, the following should return 'hello world':

$ curl -XGET http://localhost:3000/hello_world

Building chains

Suppose we didn't want just anybody to see our HelloWorld endpoint. In fact, we'd like to lock it down behind some authentication.

Our request will now have two stages, one where we check authentication details and another where we respond with our secret greeting to the world. Let's split into two pieces, one for each of the two subtasks, allowing us to reuse this authentication flow in other middlewares.

class Authentication < Coach::Middleware
  def call
    unless User.exists?(login: params[:login])
      return [ 401, {}, ['Access denied'] ]
    end

    next_middleware.call
  end
end

class HelloWorld < Coach::Middleware
  uses Authentication

  def call
    [ 200, {}, ['hello world'] ]
  end
end

Here we detach the authentication logic into its own middleware. HelloWorld now uses Authentication, and will only run if it has been called via next_middleware.call from authentication.

Notice we also use params just like you would in a normal Rails controller. Every middleware class will have access to a request object, which is an instance of ActionDispatch::Request.

Passing data through middleware

So far we've demonstrated how Coach can help you break your controller code into modular pieces. The big innovation with Coach, however, is the ability to explicitly pass your data through the middleware chain.

An example usage here is to create a HelloUser endpoint. We want to protect the route by authentication, as we did before, but this time greet the user that is logged in. Making a small modification to the Authentication middleware we showed above...

class Authentication < Coach::Middleware
  provides :user  # declare that Authentication provides :user

  def call
    return [ 401, {}, ['Access denied'] ] unless user.present?

    provide(user: user)
    next_middleware.call
  end

  def user
    @user ||= User.find_by(login: params[:login])
  end
end

class HelloUser < Coach::Middleware
  uses Authentication
  requires :user  # state that HelloUser requires this data

  def call
    # Can now access `user`, as it's been provided by Authentication
    [ 200, {}, [ "hello #{user.name}" ] ]
  end
end

# Inside config/routes.rb
Example::Application.routes.draw do
  match "/hello_user",
        to: Coach::Handler.new(HelloUser),
        via: :get
end

Coach analyses your middleware chains whenever a new Handler is created. If any middleware requires :x when its chain does not provide :x, we'll error out before the app even starts with the error:

Coach::Errors::MiddlewareDependencyNotMet: HelloUser requires keys [user] that are not provided by the middleware chain

This static verification eradicates an entire category of errors that stem from implicitly running code before hitting controller methods. It allows you to be confident that the data you require has been loaded, and makes tracing the origin of that data as simple as looking up the chain.

Configuring middlewares

By making use of middleware config hashes, you can build generalised middlewares that can be configured specifically for the chain that they are used in.

class Logger < Coach::Middleware
  def call
    # Logs the incoming request path, with a configured prefix
    Rails.logger.info("[#{config[:prefix]}] - #{request.path}")
    next_middleware.call
  end
end

class HelloUser < Coach::Middleware
  uses Logger, prefix: 'HelloUser'
  uses Authentication

  def call
    ...
  end
end

The above configures a Logger middleware to prefix it's log entries with 'HelloUser'. This is a contrived example, but at GoCardless we've created middlewares that can act as generalised resource endpoints (show, index, etc) when given the model class and some extra configuration.

Testing

The basic strategy is to test each middleware in isolation, covering all the edge cases, and then create request specs that cover a happy code path, testing each of the middlewares while they work in sequence.

Each middleware is encouraged to rely on data passed through the provide/require syntax exclusively, except in stateful operations (such as database queries). By sticking to this rule, testing becomes as simple as mocking a context hash.

Coach comes with some RSpec matchers to help simplify your testing, however they aren't required by default. You'll need to run require 'coach/rspec', we recommend putting this in your spec/spec_helper.rb or spec/rails_helper.rb file.

require 'spec_helper'

describe "/whoami" do
  let(:user) { FactoryGirl.create(:user, name: 'Clark Kent', token: 'Kryptonite') }

  context "with correct auth details" do
    it "responds with user name" do
      get "/whoami", {}, { 'Authorization' => 'Kryptonite' }
      expect(response.body).to match(/Clark Kent/)
    end
  end
end

describe Routes::Whoami do
  subject(:instance) { described_class.new(context) }
  let(:context) { { authenticated_user: double(name: "Clark Kent") } }

  it { is_expected.to respond_with_body_that_matches(/Clark Kent/) }
end

describe Middleware::AuthenticatedUser do
  subject(:instance) { described_class.new(context) }
  let(:context) do
    { request: instance_double(ActionDispatch::Request, headers: headers) }
  end

  let(:user) { FactoryGirl.create(:user, name: 'Clark Kent', token: 'Kryptonite') }

  context "with valid token" do
    it { is_expected.to call_next_middleware }
    it { is_expected.to provide(authenticated_user: user) }
  end

  context "with invalid token" do
    it { is_expected.to respond_with_status(401) }
    it { is_expected.to respond_with_body_that_matches(/access denied/i) }
  end
end

Routing

For routes that represent resource actions, Coach provides some syntactic sugar to allow concise mapping of endpoint to handler in Rails apps.

# config/routes.rb
Example::Application.routes.draw do
  router = Coach::Router.new(self)
  router.draw(Routes::Users,
              base: "/users",
              actions: [
                :index,
                :show,
                :create,
                :update,
                disable: { method: :post, url: "/:id/actions/disable" }
              ])
end

Default actions that conform to standard REST principles can be easily loaded, with the users resource being mapped to:

Method URL Description
GET /users Index all users
GET /users/:id Get user by ID
POST /users Create new user
PUT /users/:id Update user details
POST /users/:id/actions/disable Custom action routed to the given path suffix

Rendering

By now you'll probably agree that the rack response format isn't the nicest way to render responses. Coach comes sans renderer, and for a good reason.

We initially built a Coach::Renderer module, but soon realised that doing so would prevent us from open sourcing. Our Renderer was 90% logic specific to the way our APIs function, including handling/formatting of validation errors, logging of unusual events etc.

What worked well for us is a standalone Renderer class that we could require in all our middleware that needed to format responses. This pattern also led to clearer code - consistent with our preference for explicit code, stating Renderer.new_resource(...) is instantly more debuggable than an inherited method on all middlewares.

Instrumentation

Coach uses ActiveSupport::Notifications to issue events that can be used to profile middleware.

Information for how to use ActiveSupports notifications can be found here.

Event Arguments
start_handler.coach event(:middleware, :request)
start_middleware.coach event(:middleware, :request)
finish_middleware.coach start, finish, id, event(:middleware, :request)
finish_handler.coach start, finish, id, event(:middleware, :request)
request.coach event containing request data and benchmarking

Of special interest is request.coach, which publishes statistics on an entire middleware chain and request. This data is particularly useful for logging, and is our solution to Rails process_action.action_controller event emitted on controller requests.

The benchmarking data includes information on how long each middleware took to process, along with the total duration of the chain.

You can add additional metadata to the notifications published by Coach by calling the log_metadata method from inside your Coach middlewares.

class Tracking < Coach::Middleware
  requires :user

  def call
    log_metadata(user_id: user.id)
    next_middleware.call
  end
end

License & Contributing

GoCardless ♥ open source. If you do too, come join us.