Skip to content

Instantly share code, notes, and snippets.

@lygaret
Last active June 9, 2022 07:57
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lygaret/f231f31f751c64e650d3993671fc8f6d to your computer and use it in GitHub Desktop.
Save lygaret/f231f31f751c64e650d3993671fc8f6d to your computer and use it in GitHub Desktop.
gem - method hooks
-m markdown
--private
--protected
--embed-mixins
--default-return void
**/*.rb
name version summary
method-hooks
0.0.1
a small, simple mixin for adding before/after hooks to methods on your objects

Adds the ability to hook methods at a class or instance level, in order to colocate side-effects where they belong.

Designed to handle separating "plugins" out of a main class file into smaller concerns, but still needing to participate in the classes full lifecycle.

Example

require 'method-hooks'

class System
  # install by including the module
  include Accidental::MethodHooks

  # any method is a hook point
  def startup(mode)
    puts "System#startup! (#{mode})"
  end

  # any method you'd like to hook needs to be defined first
  # so, include system plugins after hook point definitions
  include System::Plugin
end

# then, add some functionality via mixin

module System::Plugin
  # the "included" callback takes care of registering hooks
  def self.included(mod)
    mod.hook(:after, :startup, method(:plugin_startup))
  end

  # hook blocks (or any callable) are passed: 
  # the target and the original method arguments.
  def plugin_startup(self, mode)
    puts "System::Plugin#plugin_startup! #{mode}"
  end
end

# and then in use, ...

s = System.new
t = System.new

# you can hook at a class level...
System.hook(:after, :startup) do |system, mode|
  puts "class-level hook! #{mode}"
end

# or at an instance level, for hooks pertaining a single instance
t.hook(:after, :startup) do |target, mode|
  assert t.equal? target
  puts "t.startup! #{mode}"
end

s.startup("some mode")
# => System#startup! (some mode)
# => System::Plugin#plugin_startup! some mode
# => class-level hook! some mode

t.startup("other mode")
# => System#startup! (other mode)
# => System::Plugin#plugin_startup! other mode
# => class-level hook! other mode
# => t.startup! other mode

Usage

Add this repo to your Gemfile:

gem "method-hooks", "~> 0", git: "https://gist.github.com/f231f31f751c64e650d3993671fc8f6d.git"
require "yaml"
header = YAML.safe_load_file("readme.md")
Gem::Specification.new do |spec|
spec.name = header["name"]
spec.version = header["version"]
spec.authors = ["Jon Raphaelson"]
spec.email = ["jon@accidental.cc"]
spec.summary = header["summary"]
spec.license = "MIT"
spec.files = Dir.glob("**/*.rb", base: __dir__)
spec.require_paths = ["."]
end
module Accidental
# Included on a class, {Accidental::MethodHooks} extends that class -- and instances of
# that class -- to support arbitrary hooks to be run before/after any method.
#
# One use case is to allow "plugins", where a plugin is a module that's included
# in a class, and can participate in that class instance's lifecycle by hooking
# lifecycle hook points exposed at the top level. See {System} for an example.
#
# Hooks are added by replacing the hooked method, and as such, hooks will not
# be run if a method is redefined, or is defined _after_ the hook attempt.
#
# @example
# class Foo
# include Accidental::MethodHooks
# def foobar
# puts "Foo#foobar"
# end
# end
#
# Foo.hook(:before, :foobar) do |foo|
# puts "hooked #{foo} before foobar!"
# end
#
# f = Foo.new#
# f.foobar
# #=> hooked #<Foo:0x000000010bce5908> before foobar!
# #=> Foo#foobar
#
# g = Foo.new
# g.hook(:after, :foobar) do |foo|
# puts "hooked g, specifically, after foobar! #{foo} == g is #{foo == g}"
# end
# #=> hooked #<Foo:0x000000010c6c84c0> before foobar!
# #=> Foo#foobar
# #=> hooked g, specifically, after foobar! #<Foo:0x000000010c6c84c0> == g is true
module MethodHooks
VALID_HOOKS = %i[before after around error].freeze
def self.included(mod)
mod.extend ClassMethods
mod.prepend InstanceMethods
end
module InstanceMethods
def initialize(...)
super(...)
# instance level hooks, through singleton classes
singleton_class.extend ClassMethods
end
def hook(...) = singleton_class.hook(...)
end
module ClassMethods
# Hook the given stage of the given method call on the reciever.
# @param hook [string] a valid hook stage, see {VALID_HOOKS}
# @param meth [symbol] the name of the method to hook, must _already_ be defined on the class/instance
# @param callee [#call,nil] a callable, which will be the target of the hook, or nil if a block is given
# @return the hooked object
def hook(hook, meth, callee = nil, &block)
raise ArgumentError, "hook: #{hook}?" unless VALID_HOOKS.include? hook
raise ArgumentError, "hook: #{name}##{meth}?" unless instance_methods.include? meth
raise ArgumentError, "hook: #{callee} _and_ block given!" if callee && block
raise ArgumentError, "hook: #{callee} not callable?" if callee && !callee.respond_to?(:call)
hooks_for(hook, meth) << (callee || block)
ensure_hook(meth)
self
end
private
# @param hook [symbol] the hook stage to search for hooks
# @param meth [symbol] the method to search for hooks
# @return [Array<#call>] a modifiable array of callable hooks for the given method/hook
def hooks_for(hook, meth)
@hooks ||= {}
@hooks[meth] ||= VALID_HOOKS.each_with_object({}) { |h, m| m[h] = [] }
@hooks[meth][hook]
end
# ensure the given method has been overridden to invoke hooks
# @param meth [symbol] the method to override with a hook
def ensure_hook(meth)
name = "__hooked_#{meth}"
return if instance_methods.include?(name.to_sym)
# redefine the hooked method to send the hooks and then call then aliased name
alias_method name, meth
define_method(meth) do |*args, **kwargs, &block|
exec = proc { |hook| hook.call(self, *args, **kwargs) }
hooks = [singleton_class, self.class]
begin
hooks.flat_map { _1.send(:hooks_for, :before, meth) }.each(&exec)
send(name, *args, **kwargs, &block).tap do
hooks.flat_map { _1.send(:hooks_for, :after, meth) }.each(&exec)
end
rescue => ex
hooks.flat_map { _1.send(:hooks_for, :error, meth) }.each { _1.call(self, ex, *args, **kwargs) }
raise
end
end
# define_method(meth) do |*args, **kwargs, &block|
# self.class.hooks_for(meth, :around)
# .reverse
# .reduce(runner) { |h, hs| ->(*a,**k) { hs.call(*a,**k,&h) } }
# .call(*args, **kwargs, &block)
# end
# return with the hook registry for this method
end
end
end
end
require "minitest/mock"
require "minitest/autorun"
require "./method-hooks"
describe Accidental::MethodHooks do
before do
@klass = Class.new do
include Accidental::MethodHooks
def some_hookpoint(*args, **kwargs, &block)
[args, kwargs, block&.call(self, args, kwargs)]
end
end
@subject = @klass.new
end
specify "with no hooks defined" do
resp = @subject.some_hookpoint(1, 2, foo: "bar") { "block response" }
refute_empty resp, "it should have been the args"
args, kwargs, block = resp
assert_equal args, [1, 2]
assert_equal kwargs, { foo: "bar" }
assert_equal block, "block response"
end
describe "with a class level hook defined" do
before do
@callspy = Minitest::Mock.new
@klass.hook(:before, :some_hookpoint) do |target, *args, **kwargs|
@callspy.hit(target, args, kwargs)
end
end
it "should have called the spy with the args" do
@callspy.expect(:hit, nil, [@subject, Array, Hash])
@subject.some_hookpoint(1, 2, foo: "bar")
assert @callspy.verify
end
it "should only have executed the caller block once (not in the hook)" do
@blockspy = Minitest::Mock.new
@blockspy.expect(:hit, nil, [@subject, [1,2], {foo:"bar"}])
@callspy.expect(:hit, nil, [@subject, Array, Hash])
@subject.some_hookpoint(1, 2, foo: "bar") do |target, args, kwargs|
@blockspy.hit(target,args,kwargs)
end
assert @blockspy.verify
assert @callspy.verify
end
end
describe "with an instance level hook defined" do
before do
@classspy = Minitest::Mock.new
@instancespy = Minitest::Mock.new
@klass.hook(:before, :some_hookpoint) do |target|
@classspy.hit(target)
end
@subject.hook(:before, :some_hookpoint) do |target|
@instancespy.hit(target)
end
end
it "should call both hooks" do
@blockspy = Minitest::Mock.new
@blockspy.expect(:hit, nil)
@classspy.expect(:hit, nil, [@subject])
@instancespy.expect(:hit, nil, [@subject])
@subject.some_hookpoint { @blockspy.hit }
assert @blockspy.verify
assert @classspy.verify
assert @instancespy.verify
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment