philiprehberger-middleware
Generic middleware stack for composing processing pipelines with conditional execution, hooks, error handling, profiling, and stack composition
Requirements
- Ruby >= 3.1
Installation
Add to your Gemfile:
gem "philiprehberger-middleware"Or install directly:
gem install philiprehberger-middlewareUsage
require "philiprehberger/middleware"
stack = Philiprehberger::Middleware::Stack.new
stack.use(->(env, next_mw) { next_mw.call(env.merge(logged: true)) }, name: :logger)
stack.use(->(env, next_mw) { next_mw.call(env.merge(authed: true)) }, name: :auth)
result = stack.call({})
# => { logged: true, authed: true }Class-Based Middleware
class TimingMiddleware
def call(env, next_mw)
start = Time.now
result = next_mw.call(env)
result.merge(elapsed: Time.now - start)
end
end
stack = Philiprehberger::Middleware::Stack.new
stack.use(TimingMiddleware.new, name: :timing)Insert, Remove, and Replace
stack = Philiprehberger::Middleware::Stack.new
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :first)
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :last)
stack.insert_before(:last, ->(env, next_mw) { next_mw.call(env) }, name: :middle)
stack.to_a # => [:first, :middle, :last]
stack.remove(:middle)
stack.to_a # => [:first, :last]
stack.replace(:first, ->(env, next_mw) { next_mw.call(env.merge(replaced: true)) })Conditional Middleware
stack.use(
->(env, next_mw) { next_mw.call(env.merge(debug: true)) },
name: :debug,
if: -> { ENV["DEBUG"] == "true" }
)
stack.use(
->(env, next_mw) { next_mw.call(env.merge(cached: true)) },
name: :cache,
unless: -> { ENV["NO_CACHE"] }
)Middleware Groups
stack = Philiprehberger::Middleware::Stack.new
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :verify_token)
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :load_user)
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :check_permissions)
stack.group(:auth, [:verify_token, :load_user, :check_permissions])
stack.disable_group(:auth) # skips all auth middleware during call
stack.group_enabled?(:auth) # => false
stack.enable_group(:auth) # re-enables the groupBefore/After Hooks
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :logging)
stack.before(:logging) { |env| env[:start] = Time.now }
stack.after(:logging) { |env| puts "Duration: #{Time.now - env[:start]}s" }Around Hooks
stack.use(->(env, next_mw) { next_mw.call(env.merge(authed: true)) }, name: :auth)
stack.around(:auth) do |env, call|
start = Time.now
result = call.call(env)
result.merge(auth_duration: Time.now - start)
endTimeout Per Middleware
stack.use(
->(env, next_mw) { next_mw.call(env) },
name: :external_api,
timeout: 5
)
# Raises Philiprehberger::Middleware::TimeoutError if middleware exceeds 5 secondsError Handling
stack.use(
->(env, next_mw) { next_mw.call(env) },
name: :risky,
on_error: ->(error, env) { puts "Error: #{error.message}" }
)Halt Mechanism
# Using env[:halt]
stack.use(lambda { |env, next_mw|
return env.merge(halt: true, reason: "unauthorized") unless env[:token]
next_mw.call(env)
}, name: :auth)
# Using Halt exception
stack.use(lambda { |env, _next_mw|
raise Philiprehberger::Middleware::Halt unless env[:token]
_next_mw.call(env)
}, name: :auth)Profiling
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :auth)
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :logger)
output = stack.profile({})
# output[:result] => the final env
# output[:timings] => [{ name: :auth, duration: 0.00012 }, { name: :logger, duration: 0.00005 }]Dry Run
stack = Philiprehberger::Middleware::Stack.new
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :logger)
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :auth)
stack.use(->(env, next_mw) { next_mw.call(env) }, name: :debug, if: -> { ENV["DEBUG"] == "true" })
# Simulate execution without running middleware bodies
stack.dry_run({})
# => [:logger, :auth] (debug is skipped because guard returns false)Stack Composition
auth_stack = Philiprehberger::Middleware::Stack.new
auth_stack.use(->(env, next_mw) { next_mw.call(env) }, name: :auth)
logging_stack = Philiprehberger::Middleware::Stack.new
logging_stack.use(->(env, next_mw) { next_mw.call(env) }, name: :logger)
auth_stack.merge(logging_stack)
auth_stack.to_a # => [:auth, :logger]API
Stack
| Method | Description |
|---|---|
.new |
Create an empty middleware stack |
#use(mw, name:, if:, unless:, on_error:, timeout:) |
Append middleware with optional guards, error handler, and timeout |
#insert_before(name, mw, name:, if:, unless:, on_error:, timeout:) |
Insert middleware before a named entry |
#insert_after(name, mw, name:, if:, unless:, on_error:, timeout:) |
Insert middleware after a named entry |
#remove(name) |
Remove middleware by name |
#replace(name, mw, name:) |
Replace a named middleware, preserving position and guards |
#[](name) |
Look up middleware by name, returns nil if not found |
#call(env) |
Execute the stack with the given environment |
#profile(env) |
Execute the stack and return { result:, timings: } with per-middleware durations |
#dry_run(env) |
Simulate execution and return ordered list of middleware names that would run |
#merge(other_stack) |
Append all entries from another stack |
#to_a |
List middleware names in order |
#group(name, middleware_names) |
Define a named group of middleware |
#enable_group(name) |
Enable a previously disabled middleware group |
#disable_group(name) |
Disable a middleware group so its entries are skipped |
#group_enabled?(name) |
Check if a middleware group is enabled |
#before(name, &block) |
Attach a hook that runs before the named middleware |
#after(name, &block) |
Attach a hook that runs after the named middleware |
#around(name, &block) |
Attach a wrapping hook around the named middleware (receives env and a callable) |
#clear |
Remove all middleware entries, groups, and hooks |
#swap(name1, name2) |
Swap positions of two named entries |
#stats |
Return metadata hash with count, named, groups, and hooks |
#describe |
Return a human-readable stack summary |
#frozen_copy |
Return an immutable snapshot that can execute but not be modified |
Development
bundle install
bundle exec rspec
bundle exec rubocopSupport
If you find this project useful: