Class: Otto::Core::MiddlewareStack

Inherits:
Object
  • Object
show all
Includes:
Enumerable, Freezable
Defined in:
lib/otto/core/middleware_stack.rb

Overview

Enhanced middleware stack management for Otto framework. Provides better middleware registration, introspection capabilities, and improved execution chain management.

Constant Summary collapse

PIN_TIERS =

Pin tiers honored by #ordered_stack, outward-ascending. #wrap folds the stack with reduce, so a LATER array position is a FURTHER OUT wrapper; sorting by tier therefore sorts by how early the middleware sees the request. Unpinned entries are tier 0 and keep their insertion order.

A tier is recorded on the ENTRY (as entry), not on the middleware class. Entries are identified by (class, args, options), so the same class can legitimately be registered more than once with different configuration; a class-wide pin would drag those other registrations into the pinned tier along with it.

{
   outermost: 1,
  entrypoint: 2,
}.freeze
MCP_MIDDLEWARE_EXECUTION_ORDER =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

MCP middleware in security-optimal EXECUTION order, outermost first.

[
  'Otto::MCP::RateLimitMiddleware',
  'Otto::MCP::Auth::TokenMiddleware',
  'Otto::MCP::SchemaValidationMiddleware',
].freeze

Instance Method Summary collapse

Methods included from Freezable

#deep_freeze!

Constructor Details

#initializeMiddlewareStack

Returns a new instance of MiddlewareStack.



31
32
33
34
35
# File 'lib/otto/core/middleware_stack.rb', line 31

def initialize
  @stack = []
  @middleware_set = Set.new
  @on_change_callback = nil
end

Instance Method Details

#add(middleware_class, *args, **options) ⇒ Object Also known as: use, <<

Enhanced middleware registration with argument uniqueness and immutability check

Raises:

  • (FrozenError)


44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/otto/core/middleware_stack.rb', line 44

def add(middleware_class, *args, **options)
  # Prevent modifications to frozen configurations
  raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?

  # Check if an identical middleware configuration already exists
  existing_entry = @stack.find do |entry|
    entry[:middleware] == middleware_class &&
      entry[:args] == args &&
      entry[:options] == options
  end

  # Only add if no identical middleware configuration exists
  return if existing_entry

  entry = { middleware: middleware_class, args: args, options: options }
  @stack << entry
  @middleware_set.add(middleware_class)
  # Notify of change
  @on_change_callback&.call
end

#add_with_position(middleware_class, *args, position: nil, **options) ⇒ Object

Add middleware with position hint for optimal ordering

Positions name a place in the ARRAY; #wrap folds the array with reduce, so array order is the REVERSE of execution order — the last entry is the outermost wrapper and therefore the first to see a request.

  • :first/:innermost — innermost: the LAST middleware to see the request, closest to the app. Note the trap in the older :first spelling: it is first-in-array, hence last-to-execute. :innermost says the same thing in execution terms and is the preferred spelling.
  • :last/nil — append (outermost among currently-registered middleware, but a later append displaces it)
  • :outermost — pin to run OUTERMOST (first to see the request) and STAY there even if more middleware is appended afterward. Unlike :last, this is order-independent: honored in #ordered_stack at build time. Use for middleware that must short-circuit ahead of everything else (e.g. the CSP report receiver, which must intercept before CSRF).
  • :entrypoint — pin OUTSIDE even the :outermost tier: the very first middleware to touch a request. Reserved for middleware that must normalize the request before anything else can observe it. Otto pins IPPrivacyMiddleware here so every other middleware — its own, an :outermost pin, and anything the app adds via Otto#use — reads a masked REMOTE_ADDR and the canonical env.

Parameters:

  • middleware_class (Class)

    Middleware class

  • args (Array)

    Middleware arguments

  • position (Symbol, nil) (defaults to: nil)

    Position hint (:first, :innermost, :last, :outermost, :entrypoint, or nil)

Raises:

  • (FrozenError)


96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/otto/core/middleware_stack.rb', line 96

def add_with_position(middleware_class, *args, position: nil, **options)
  raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?

  # Check for identical configuration
  existing_entry = @stack.find do |entry|
    entry[:middleware] == middleware_class &&
      entry[:args] == args &&
      entry[:options] == options
  end

  return if existing_entry

  entry = { middleware: middleware_class, args: args, options: options }

  case position
  when :first, :innermost
    @stack.unshift(entry)
  when *PIN_TIERS.keys
    @stack << entry.merge(pin_tier: PIN_TIERS.fetch(position))
  else
    @stack << entry # :last / nil — default append
  end

  @middleware_set.add(middleware_class)
  # Notify of change
  @on_change_callback&.call
end

#clear!Object

Clear all middleware

Raises:

  • (FrozenError)


202
203
204
205
206
207
208
209
210
# File 'lib/otto/core/middleware_stack.rb', line 202

def clear!
  # Prevent modifications to frozen configurations
  raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?

  @stack.clear
  @middleware_set.clear
  # Notify of change
  @on_change_callback&.call
end

#count(middleware_class) ⇒ Object

Count occurrences of a specific middleware class



287
288
289
# File 'lib/otto/core/middleware_stack.rb', line 287

def count(middleware_class)
  @stack.count { |entry| entry[:middleware] == middleware_class }
end

#eachObject

Enumerable support



213
214
215
# File 'lib/otto/core/middleware_stack.rb', line 213

def each(&)
  @stack.each(&)
end

#empty?Boolean

Returns:

  • (Boolean)


282
283
284
# File 'lib/otto/core/middleware_stack.rb', line 282

def empty?
  @stack.empty?
end

#execution_orderArray<Class>

Returns middleware classes in EXECUTION order: the first entry is the outermost wrapper #wrap builds, i.e. the first to see a request. This is #middleware_list resolved through the pin tiers and reversed, so it answers "what does this stack actually do?" without building the app.

Returns:

  • (Array<Class>)

    outermost (first to execute) first



262
263
264
# File 'lib/otto/core/middleware_stack.rb', line 262

def execution_order
  ordered_stack.reverse.map { |entry| entry[:middleware] }
end

#includes?(middleware_class) ⇒ Boolean

Check if middleware is registered - now O(1) using Set

Returns:

  • (Boolean)


197
198
199
# File 'lib/otto/core/middleware_stack.rb', line 197

def includes?(middleware_class)
  @middleware_set.include?(middleware_class)
end

#middleware_detailsObject

Detailed introspection



267
268
269
270
271
272
273
274
275
# File 'lib/otto/core/middleware_stack.rb', line 267

def middleware_details
  @stack.map do |entry|
    {
      middleware: entry[:middleware],
            args: entry[:args],
         options: entry[:options],
    }
  end
end

#middleware_listObject

Returns list of middleware classes in REGISTRATION order — the order they were added, which is the reverse of execution order and ignores pin tiers. Use #execution_order to see what actually runs first.



252
253
254
# File 'lib/otto/core/middleware_stack.rb', line 252

def middleware_list
  @stack.map { |entry| entry[:middleware] }
end

#on_change(&callback) ⇒ Object

Set a callback to be invoked when the middleware stack changes

Parameters:

  • callback (Proc)

    A callable object (e.g., method or lambda)



39
40
41
# File 'lib/otto/core/middleware_stack.rb', line 39

def on_change(&callback)
  @on_change_callback = callback
end

#remove(middleware_class) ⇒ Object

Remove middleware

Raises:

  • (FrozenError)


180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/otto/core/middleware_stack.rb', line 180

def remove(middleware_class)
  # Prevent modifications to frozen configurations
  raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?

  matches = @stack.reject! { |entry| entry[:middleware] == middleware_class }

  # Update middleware set if any matching entries were found
  return unless matches

  # Rebuild the set of unique middleware classes. Pins need no cleanup:
  # each removed entry took its own tier with it.
  @middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
  # Notify of change
  @on_change_callback&.call
end

#reverse_eachObject

Legacy compatibility methods for existing Otto interface



294
295
296
# File 'lib/otto/core/middleware_stack.rb', line 294

def reverse_each(&)
  @stack.reverse_each(&)
end

#sizeObject

Statistics



278
279
280
# File 'lib/otto/core/middleware_stack.rb', line 278

def size
  @stack.size
end

#validate_mcp_middleware_orderArray<String>

Validate MCP middleware ordering.

MCP middleware must run in this order, where "before" means "sees the request first" — i.e. is the further-out wrapper:

  1. RateLimitMiddleware (shed excessive load early)
  2. Auth middleware (validate credentials before parsing)
  3. SchemaValidationMiddleware (expensive JSON schema validation last)

This reasons over #execution_order, NOT over raw array indices. Array order is the reverse of execution order (see #add_with_position), so the earlier index-based implementation reported exactly the wrong stacks as correct.

Returns:

  • (Array<String>)

    Warning messages if order is suboptimal



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/otto/core/middleware_stack.rb', line 146

def validate_mcp_middleware_order
  warnings = []

  # 0-3 items in practice; clarity beats a single-pass micro-optimization.
  mcp_middlewares = execution_order.select do |middleware|
    MCP_MIDDLEWARE_EXECUTION_ORDER.include?(middleware.to_s)
  end

  return warnings if mcp_middlewares.size < 2

  # Positions in EXECUTION order: smaller means it sees the request
  # sooner. The same class can be registered more than once (entries are
  # keyed on class + args + options), so compare the LAST occurrence of
  # the outer middleware against the FIRST occurrence of the inner one:
  # the order is only right when every outer occurrence precedes every
  # inner occurrence. Checking first-vs-first would pass a stack shaped
  # rate-limit, auth, rate-limit.
  first_position = ->(name) { mcp_middlewares.find_index { |m| m.to_s == name } }
  last_position  = ->(name) { mcp_middlewares.rindex { |m| m.to_s == name } }

  MCP_MIDDLEWARE_EXECUTION_ORDER.combination(2) do |outer, inner|
    outer_pos = last_position.call(outer)
    inner_pos = first_position.call(inner)
    next unless outer_pos && inner_pos && outer_pos > inner_pos

    warnings << "[MCP Middleware] #{outer.split('::').last} should run before #{inner.split('::').last}"
  end

  warnings
end

#wrap(base_app, security_config = nil) ⇒ Object

Build Rack application with middleware chain

The stack folds via reduce, so the LAST entry becomes the OUTERMOST wrapper (first to see the request). #ordered_stack moves any pinned middleware (:outermost, :entrypoint) to the end so it stays outermost regardless of the order middleware was registered in.

NOT a request-path method. Its only caller is Otto's build_app!, which runs at construction and again whenever the stack changes; requests are served by the chain it returns. So the ordering work here (and in #ordered_stack) is per-BUILD, not per-request — don't add caching machinery on the assumption that it is hot.



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/otto/core/middleware_stack.rb', line 229

def wrap(base_app, security_config = nil)
  ordered_stack.reduce(base_app) do |app, entry|
    middleware = entry[:middleware]
    args = entry[:args]
    options = entry[:options]

    if middleware.respond_to?(:new)
      # Inject security_config for security middleware, placing it before custom args
      if security_config && middleware_needs_config?(middleware)
        middleware.new(app, security_config, *args, **options)
      else
        middleware.new(app, *args, **options)
      end
    else
      # Proc-based middleware
      middleware.call(app)
    end
  end
end