The project is in a healthy, maintained state
A composable middleware stack that supports lambda and class-based middleware, named entries with insert-before/after and removal, conditional guards, error handling, middleware groups, before/after/around hooks, per-middleware timeouts, and profiling.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

philiprehberger-middleware

Tests Gem Version Last updated

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-middleware

Usage

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 group

Before/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)
end

Timeout Per Middleware

stack.use(
  ->(env, next_mw) { next_mw.call(env) },
  name: :external_api,
  timeout: 5
)
# Raises Philiprehberger::Middleware::TimeoutError if middleware exceeds 5 seconds

Error 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 rubocop

Support

If you find this project useful:

Star the repo

🐛 Report issues

💡 Suggest features

❤️ Sponsor development

🌐 All Open Source Projects

💻 GitHub Profile

🔗 LinkedIn Profile

License

MIT