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)


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

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)


205
206
207
208
# File 'lib/philiprehberger/middleware/stack.rb', line 205

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)


181
182
183
184
# File 'lib/philiprehberger/middleware/stack.rb', line 181

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



214
215
216
217
218
219
220
221
222
# File 'lib/philiprehberger/middleware/stack.rb', line 214

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)


280
281
282
283
284
285
286
287
288
# File 'lib/philiprehberger/middleware/stack.rb', line 280

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



340
341
342
343
344
345
346
347
348
349
350
# File 'lib/philiprehberger/middleware/stack.rb', line 340

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)


163
164
165
166
# File 'lib/philiprehberger/middleware/stack.rb', line 163

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



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/philiprehberger/middleware/stack.rb', line 245

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

#enable_group(group_name) ⇒ self

Enable a middleware group.

Parameters:

  • group_name (Symbol)

    name of the group to enable

Returns:

  • (self)


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

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



355
356
357
358
# File 'lib/philiprehberger/middleware/stack.rb', line 355

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)


145
146
147
148
# File 'lib/philiprehberger/middleware/stack.rb', line 145

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)



172
173
174
# File 'lib/philiprehberger/middleware/stack.rb', line 172

def group_enabled?(group_name)
  !@disabled_groups.include?(group_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:



270
271
272
273
274
275
# File 'lib/philiprehberger/middleware/stack.rb', line 270

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: }



309
310
311
312
313
314
315
316
317
318
319
# File 'lib/philiprehberger/middleware/stack.rb', line 309

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: }, …] }



228
229
230
231
232
233
234
235
# File 'lib/philiprehberger/middleware/stack.rb', line 228

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

#statsHash

Return metadata hash about the stack.

Returns:

  • (Hash)

    count, named, groups, and hooks metadata



324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/philiprehberger/middleware/stack.rb', line 324

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



296
297
298
299
300
301
# File 'lib/philiprehberger/middleware/stack.rb', line 296

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



363
364
365
# File 'lib/philiprehberger/middleware/stack.rb', line 363

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