Class: Philiprehberger::Middleware::Stack

Inherits:
Object
  • Object
show all
Defined in:
lib/philiprehberger/middleware/stack.rb

Overview

A composable middleware stack for processing pipelines.

Middleware can be a lambda ‘(env, next_mw)` or an object responding to `#call(env, next_mw)`.

Defined Under Namespace

Classes: Entry

Instance Method Summary collapse

Constructor Details

#initializeStack

Create a new empty middleware stack.



13
14
15
16
17
18
19
20
21
22
23
24
# File 'lib/philiprehberger/middleware/stack.rb', line 13

def initialize
  @entries = []
  @groups = {}
  @disabled_groups = Set.new
  @before_hooks = Hash.new { |h, k| h[k] = [] }
  @after_hooks = Hash.new { |h, k| h[k] = [] }
  @around_hooks = Hash.new { |h, k| h[k] = [] }
  @metrics_mutex = Mutex.new
  @metrics_invocations = 0
  @metrics_total_time = 0.0
  @metrics_last_called_at = nil
end

Instance Method Details

#[](target_name) ⇒ #call?

Look up an entry by name.

Parameters:

  • target_name (String, Symbol)

    name of the entry

Returns:

  • (#call, nil)

    the middleware callable, or nil if not found



135
136
137
138
# File 'lib/philiprehberger/middleware/stack.rb', line 135

def [](target_name)
  entry = @entries.find { |e| e.name == target_name }
  entry&.middleware
end

#after(middleware_name) {|env| ... } ⇒ self

Attach an after hook to a named middleware.

Parameters:

  • middleware_name (Symbol)

    name of the middleware to hook

Yields:

  • (env)

    block to execute after the middleware

Returns:

  • (self)


211
212
213
214
# File 'lib/philiprehberger/middleware/stack.rb', line 211

def after(middleware_name, &block)
  @after_hooks[middleware_name] << block
  self
end

#around(middleware_name) {|env, call| ... } ⇒ self

Attach an around hook to a named middleware.

The block wraps the middleware execution (including before/after hooks). It receives the env and a callable; invoke the callable to run the middleware. Multiple around hooks nest in registration order (first registered = outermost).

Parameters:

  • middleware_name (Symbol)

    name of the middleware to hook

Yields:

  • (env, call)

    block that wraps execution; call ‘call.call(env)` to proceed

Returns:

  • (self)


225
226
227
228
# File 'lib/philiprehberger/middleware/stack.rb', line 225

def around(middleware_name, &block)
  @around_hooks[middleware_name] << block
  self
end

#before(middleware_name) {|env| ... } ⇒ self

Attach a before hook to a named middleware.

Parameters:

  • middleware_name (Symbol)

    name of the middleware to hook

Yields:

  • (env)

    block to execute before the middleware

Returns:

  • (self)


201
202
203
204
# File 'lib/philiprehberger/middleware/stack.rb', line 201

def before(middleware_name, &block)
  @before_hooks[middleware_name] << block
  self
end

#call(env) ⇒ Object

Execute the middleware stack with the given environment.

Parameters:

  • env (Object)

    the environment/context passed through the stack

Returns:

  • (Object)

    the final environment after all middleware have run



234
235
236
237
238
239
240
241
242
# File 'lib/philiprehberger/middleware/stack.rb', line 234

def call(env)
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  chain = build_chain
  chain.call(env)
rescue Halt
  env
ensure
  record_invocation(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at)
end

#clearself

Remove all middleware entries, groups, and hooks.

Returns:

  • (self)


300
301
302
303
304
305
306
307
308
# File 'lib/philiprehberger/middleware/stack.rb', line 300

def clear
  @entries.clear
  @groups.clear
  @disabled_groups.clear
  @before_hooks.clear
  @after_hooks.clear
  @around_hooks.clear
  self
end

#describeString

Return a human-readable stack summary.

Returns:

  • (String)

    multi-line description of the stack



360
361
362
363
364
365
366
367
368
369
370
# File 'lib/philiprehberger/middleware/stack.rb', line 360

def describe
  lines = @entries.map.with_index do |entry, idx|
    parts = ["#{idx}: #{entry.name || '(unnamed)'}"]
    parts << 'if-guarded' if entry.if_guard
    parts << 'unless-guarded' if entry.unless_guard
    parts << "timeout=#{entry.timeout}s" if entry.timeout
    parts << 'on_error' if entry.on_error
    parts.join(' | ')
  end
  lines.join("\n")
end

#disable_group(group_name) ⇒ self

Disable a middleware group.

Parameters:

  • group_name (Symbol)

    name of the group to disable

Returns:

  • (self)


183
184
185
186
# File 'lib/philiprehberger/middleware/stack.rb', line 183

def disable_group(group_name)
  @disabled_groups.add(group_name)
  self
end

#dry_run(env) ⇒ Array<Symbol, String, nil>

Simulate the middleware stack without executing middleware bodies.

Returns the ordered list of middleware names that would be executed, respecting disabled groups, conditional guards, and the halt mechanism. Useful for debugging and testing pipeline configuration.

Parameters:

  • env (Object)

    the environment/context used to evaluate halt conditions

Returns:

  • (Array<Symbol, String, nil>)

    names of middleware that would execute



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/philiprehberger/middleware/stack.rb', line 265

def dry_run(env)
  disabled = disabled_middleware_names
  names = []

  @entries.each do |entry|
    # Skip disabled group members
    next if entry.name && disabled.include?(entry.name)

    # Check halt condition
    break if env.is_a?(Hash) && env[:halt]

    # Evaluate conditional guards
    next if entry.if_guard && !entry.if_guard.call
    next if entry.unless_guard&.call

    names << entry.name
  end

  names
end

#empty?Boolean

Whether the stack has zero middleware entries.

Returns:

  • (Boolean)


399
400
401
# File 'lib/philiprehberger/middleware/stack.rb', line 399

def empty?
  @entries.empty?
end

#enable_group(group_name) ⇒ self

Enable a middleware group.

Parameters:

  • group_name (Symbol)

    name of the group to enable

Returns:

  • (self)


174
175
176
177
# File 'lib/philiprehberger/middleware/stack.rb', line 174

def enable_group(group_name)
  @disabled_groups.delete(group_name)
  self
end

#frozen_copyFrozenStack

Return an immutable copy of the stack.

Returns:

  • (FrozenStack)

    a frozen copy that can execute but not be modified



375
376
377
378
# File 'lib/philiprehberger/middleware/stack.rb', line 375

def frozen_copy
  FrozenStack.new(@entries.map(&:dup), @groups.dup, @disabled_groups.dup, @before_hooks.dup, @after_hooks.dup,
                  @around_hooks.dup)
end

#group(group_name, middleware_names) ⇒ self

Define a named group of middleware.

Parameters:

  • group_name (Symbol)

    name of the group

  • middleware_names (Array<Symbol>)

    names of middleware in the group

Returns:

  • (self)


165
166
167
168
# File 'lib/philiprehberger/middleware/stack.rb', line 165

def group(group_name, middleware_names)
  @groups[group_name] = middleware_names.dup
  self
end

#group_enabled?(group_name) ⇒ Boolean

Check if a middleware group is enabled.

Parameters:

  • group_name (Symbol)

    name of the group

Returns:

  • (Boolean)

    true if the group is enabled (or not defined as a group)



192
193
194
# File 'lib/philiprehberger/middleware/stack.rb', line 192

def group_enabled?(group_name)
  !@disabled_groups.include?(group_name)
end

#has?(target_name) ⇒ Boolean

Whether the stack contains an entry with the given name.

Unlike #[], this does not return the middleware itself — useful when callers need an unambiguous yes/no without conflating an absent entry with one stored as nil.

Parameters:

  • target_name (String, Symbol)

    name of the entry

Returns:

  • (Boolean)


148
149
150
# File 'lib/philiprehberger/middleware/stack.rb', line 148

def has?(target_name)
  @entries.any? { |e| e.name == target_name }
end

#index_of(target_name) ⇒ Integer?

Position of a named entry in the stack, or nil when absent.

Parameters:

  • target_name (String, Symbol)

    name of the entry

Returns:

  • (Integer, nil)


156
157
158
# File 'lib/philiprehberger/middleware/stack.rb', line 156

def index_of(target_name)
  @entries.index { |e| e.name == target_name }
end

#insert_after(target_name, middleware, name: nil, if: nil, unless: nil, on_error: nil, timeout: nil) ⇒ self

Insert a middleware after the named entry.

Parameters:

  • target_name (String, Symbol)

    name of the entry to insert after

  • middleware (#call, Proc)

    middleware callable

  • name (String, Symbol, nil) (defaults to: nil)

    optional name for the new entry

  • if_opt (Proc, nil)

    guard – middleware runs only when this returns truthy

  • unless_opt (Proc, nil)

    guard – middleware runs only when this returns falsey

  • on_error (Proc, nil) (defaults to: nil)

    error handler called with (error, env) when the middleware raises

  • timeout (Numeric, nil) (defaults to: nil)

    optional timeout in seconds for middleware execution

Returns:

  • (self)

Raises:

  • (Error)

    if the target name is not found



84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/philiprehberger/middleware/stack.rb', line 84

def insert_after(target_name, middleware, name: nil, if: nil, unless: nil, on_error: nil, timeout: nil)
  validate_middleware!(middleware)
  index = find_index!(target_name)
  @entries.insert(index + 1, Entry.new(
                               middleware: middleware,
                               name: name,
                               if_guard: binding.local_variable_get(:if),
                               unless_guard: binding.local_variable_get(:unless),
                               on_error: on_error,
                               timeout: timeout
                             ))
  self
end

#insert_before(target_name, middleware, name: nil, if: nil, unless: nil, on_error: nil, timeout: nil) ⇒ self

Insert a middleware before the named entry.

Parameters:

  • target_name (String, Symbol)

    name of the entry to insert before

  • middleware (#call, Proc)

    middleware callable

  • name (String, Symbol, nil) (defaults to: nil)

    optional name for the new entry

  • if_opt (Proc, nil)

    guard – middleware runs only when this returns truthy

  • unless_opt (Proc, nil)

    guard – middleware runs only when this returns falsey

  • on_error (Proc, nil) (defaults to: nil)

    error handler called with (error, env) when the middleware raises

  • timeout (Numeric, nil) (defaults to: nil)

    optional timeout in seconds for middleware execution

Returns:

  • (self)

Raises:

  • (Error)

    if the target name is not found



59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/philiprehberger/middleware/stack.rb', line 59

def insert_before(target_name, middleware, name: nil, if: nil, unless: nil, on_error: nil, timeout: nil)
  validate_middleware!(middleware)
  index = find_index!(target_name)
  @entries.insert(index, Entry.new(
                           middleware: middleware,
                           name: name,
                           if_guard: binding.local_variable_get(:if),
                           unless_guard: binding.local_variable_get(:unless),
                           on_error: on_error,
                           timeout: timeout
                         ))
  self
end

#merge(other) ⇒ self

Merge another stack’s entries onto the end of this stack.

Parameters:

  • other (Stack)

    the stack to merge from

Returns:

  • (self)

Raises:



290
291
292
293
294
295
# File 'lib/philiprehberger/middleware/stack.rb', line 290

def merge(other)
  raise Error, 'argument must be a Stack' unless other.is_a?(Stack)

  other.each_entry { |entry| @entries << entry.dup }
  self
end

#metricsHash

Return whole-stack invocation metrics aggregated across every #call since construction.

Unlike #stats (structural metadata) and #profile (per-middleware timings), this hash summarizes how often the stack has been executed and the total wall-clock time spent.

Returns:

  • (Hash)

    { invocations:, total_time:, avg_time:, last_called_at: }



329
330
331
332
333
334
335
336
337
338
339
# File 'lib/philiprehberger/middleware/stack.rb', line 329

def metrics
  @metrics_mutex.synchronize do
    avg = @metrics_invocations.zero? ? 0.0 : @metrics_total_time / @metrics_invocations
    {
      invocations: @metrics_invocations,
      total_time: @metrics_total_time,
      avg_time: avg,
      last_called_at: @metrics_last_called_at
    }
  end
end

#profile(env) ⇒ Hash

Execute the middleware stack and return profiling data.

Parameters:

  • env (Object)

    the environment/context passed through the stack

Returns:

  • (Hash)

    { result: <env>, timings: [{ name:, duration: }, …] }



248
249
250
251
252
253
254
255
# File 'lib/philiprehberger/middleware/stack.rb', line 248

def profile(env)
  timings = []
  chain = build_chain(timings: timings)
  result = chain.call(env)
  { result: result, timings: timings }
rescue Halt
  { result: env, timings: timings }
end

#remove(target_name) ⇒ self

Remove a middleware by name.

Parameters:

  • target_name (String, Symbol)

    name of the entry to remove

Returns:

  • (self)

Raises:

  • (Error)

    if the target name is not found



103
104
105
106
107
# File 'lib/philiprehberger/middleware/stack.rb', line 103

def remove(target_name)
  index = find_index!(target_name)
  @entries.delete_at(index)
  self
end

#replace(target_name, middleware, name: nil) ⇒ self

Replace a named middleware with a new one.

Parameters:

  • target_name (String, Symbol)

    name of the entry to replace

  • middleware (#call, Proc)

    the replacement middleware

  • name (String, Symbol, nil) (defaults to: nil)

    optional new name (defaults to existing name)

Returns:

  • (self)

Raises:

  • (Error)

    if the target name is not found



116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/philiprehberger/middleware/stack.rb', line 116

def replace(target_name, middleware, name: nil)
  validate_middleware!(middleware)
  index = find_index!(target_name)
  old_entry = @entries[index]
  @entries[index] = Entry.new(
    middleware: middleware,
    name: name || old_entry.name,
    if_guard: old_entry.if_guard,
    unless_guard: old_entry.unless_guard,
    on_error: old_entry.on_error,
    timeout: old_entry.timeout
  )
  self
end

#sizeInteger Also known as: length

Number of middleware entries in the stack.

Returns:

  • (Integer)


390
391
392
# File 'lib/philiprehberger/middleware/stack.rb', line 390

def size
  @entries.length
end

#statsHash

Return metadata hash about the stack.

Returns:

  • (Hash)

    count, named, groups, and hooks metadata



344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/philiprehberger/middleware/stack.rb', line 344

def stats
  {
    count: @entries.length,
    named: @entries.count(&:name),
    groups: @groups.keys,
    hooks: {
      before: @before_hooks.keys,
      after: @after_hooks.keys,
      around: @around_hooks.keys
    }
  }
end

#swap(name1, name2) ⇒ self

Swap positions of two named entries.

Parameters:

  • name1 (String, Symbol)

    name of the first entry

  • name2 (String, Symbol)

    name of the second entry

Returns:

  • (self)

Raises:

  • (Error)

    if either name is not found



316
317
318
319
320
321
# File 'lib/philiprehberger/middleware/stack.rb', line 316

def swap(name1, name2)
  idx1 = find_index!(name1)
  idx2 = find_index!(name2)
  @entries[idx1], @entries[idx2] = @entries[idx2], @entries[idx1]
  self
end

#to_aArray<String, Symbol, nil>

Return the list of middleware names/entries.

Returns:

  • (Array<String, Symbol, nil>)

    names of middleware in order



383
384
385
# File 'lib/philiprehberger/middleware/stack.rb', line 383

def to_a
  @entries.map(&:name)
end

#use(middleware, name: nil, if: nil, unless: nil, on_error: nil, timeout: nil) ⇒ self

Append a middleware to the end of the stack.

Parameters:

  • middleware (#call, Proc)

    middleware callable accepting (env, next_mw)

  • name (String, Symbol, nil) (defaults to: nil)

    optional name for insertion/removal

  • if_opt (Proc, nil)

    guard – middleware runs only when this returns truthy

  • unless_opt (Proc, nil)

    guard – middleware runs only when this returns falsey

  • on_error (Proc, nil) (defaults to: nil)

    error handler called with (error, env) when the middleware raises

  • timeout (Numeric, nil) (defaults to: nil)

    optional timeout in seconds for middleware execution

Returns:

  • (self)


35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/philiprehberger/middleware/stack.rb', line 35

def use(middleware, name: nil, if: nil, unless: nil, on_error: nil, timeout: nil)
  validate_middleware!(middleware)
  @entries << Entry.new(
    middleware: middleware,
    name: name,
    if_guard: binding.local_variable_get(:if),
    unless_guard: binding.local_variable_get(:unless),
    on_error: on_error,
    timeout: timeout
  )
  self
end