The real machine behind config.middleware

Free preview 3 min to read

The last course's closing challenge asked you to write run_chain(env, middlewares, final_app). You start from final_app, walk the middleware list backward, and wrap each one around the current app. If your solution looked like middlewares.reverse_each { |mw| app = mw.new(app) }, you did more than pass the tests. You wrote the same line Rails runs on every boot, in production.

The real source

ActionDispatch::MiddlewareStack is the class config.middleware builds in the end. It lives in lib/action_dispatch/middleware/stack.rb inside actionpack-8.1.3.1, and it has a build method:

def build(app = nil, &block)
  instrumenting = ActiveSupport::Notifications.notifier.listening?(InstrumentationProxy::EVENT_NAME)
  middlewares.freeze.reverse.inject(app || block) do |a, e|
    if instrumenting
      e.build_instrumented(a)
    else
      e.build(a)
    end
  end
end

Set aside the instrumentation branch. It wraps each middleware in a class that fires an ActiveSupport::Notifications event around call, which helps with profiling but has nothing to do with the wrapping. What's left is middlewares.freeze.reverse.inject(app) { |a, e| e.build(a) }. That is the reverse-then-wrap shape of run_chain, written with inject instead of a loop that reassigns a variable.

What e.build(a) does

Each e here is an instance of Middleware, a small class inside MiddlewareStack, not your middleware class. It remembers the class, the constructor args, and the block you passed to use, insert_after and the rest:

class Middleware
  attr_reader :args, :block, :klass

  def initialize(klass, args, block)
    @klass = klass
    @args  = args
    @block = block
  end

  def build(app)
    klass.new(app, *args, &block)
  end
end

build(app) is one line: klass.new(app, *args, &block). For a plain config.middleware.use RequestTimingLogger, that's RequestTimingLogger.new(app), the same initialize(app) shape as in the last course. The *args, &block are there for calls that pass more. This app's config/initializers/omniauth.rb calls config.middleware.use OmniAuth::Builder do ... end, and the block goes straight into OmniAuth::Builder.new(app, &block).

Why the match matters

The match is the point of the last course's challenge. ActionDispatch::MiddlewareStack is not a black box. It's the small, readable idea you already built by hand, with a small Middleware class and an inject in place of a bare loop. Every time this app boots, in development or production, this build method runs once. It walks the app's config.middleware list backward (29 entries in development, 26 in production) and returns one nested object, with run NewbieOnRails::Application.routes at its center. There is no further layer underneath.

Key takeaways

  • ActionDispatch::MiddlewareStack#build in actionpack-8.1.3.1 comes down to middlewares.freeze.reverse.inject(app) { |a, e| e.build(a) }: the same reverse-then-wrap shape as the previous course's run_chain challenge.
  • Each stack entry is a Middleware object that remembers the class, the constructor args and the block. Its build(app) is one line: klass.new(app, *args, &block).
  • Forwarding *args, &block is why this app's config.middleware.use OmniAuth::Builder do ... end works: the block given to use reaches the middleware's own constructor.
  • This runs once per boot on the app's stack (29 entries in development, 26 in production) and produces the nested chain bin/rails middleware prints. Nothing else is hidden beneath it.
Lesson 1 of 7 · free

Keep going with lesson 2

Recording, not applying: the MiddlewareStackProxy

Calls to config.middleware push lambdas onto arrays, and merge_into replays them late in boot against the default stack.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...