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[:pin_tier]), 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

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[‘otto.client_ip’].

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 be in security-optimal order: 1. RateLimitMiddleware (reject excessive requests early) 2. Auth middleware (validate credentials before parsing) 3. SchemaValidationMiddleware (expensive JSON schema validation last)

Returns:

  • (Array<String>)

    Warning messages if order is suboptimal



132
133
134
135
136
137
138
139
140
141
142
143
144
145
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 132

def validate_mcp_middleware_order
  warnings = []

  # PERFORMANCE NOTE: This implementation intentionally uses select + find_index
  # rather than a single-pass approach. The filtered mcp_middlewares array is
  # typically 0-3 items, making the performance difference unmeasurable.
  # The current approach prioritizes readability over micro-optimization.
  # Single-pass alternatives were considered but rejected as premature optimization.
  mcp_middlewares = @stack.select do |entry|
    [
      Otto::MCP::RateLimitMiddleware,
      Otto::MCP::Auth::TokenMiddleware,
      Otto::MCP::SchemaValidationMiddleware,
    ].include?(entry[:middleware])
  end

  return warnings if mcp_middlewares.size < 2

  # Find positions
  rate_limit_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::RateLimitMiddleware }
  auth_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::Auth::TokenMiddleware }
  validation_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::SchemaValidationMiddleware }

  # Check optimal order: rate_limit < auth < validation
  if rate_limit_pos && auth_pos && rate_limit_pos > auth_pos
    warnings << <<~MSG.chomp
      [MCP Middleware] RateLimitMiddleware should come before TokenMiddleware
    MSG
  end

  if auth_pos && validation_pos && auth_pos > validation_pos
    warnings << <<~MSG.chomp
      [MCP Middleware] TokenMiddleware should come before SchemaValidationMiddleware
    MSG
  end

  if rate_limit_pos && validation_pos && rate_limit_pos > validation_pos
    warnings << <<~MSG.chomp
      [MCP Middleware] RateLimitMiddleware should come before SchemaValidationMiddleware
    MSG
  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