gem - method hooks

Jonathan Raphaelson
2022-06-09T07:37:43Z

.yardopts

| -m markdown
| --private
| --protected
| --embed-mixins
| --default-return void
| **/*.rb

readme.md

name | method-hooks
version | 0.0.1
summary | 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"

method-hooks.gemspec

| 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

method-hooks.rb

| 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

method-hooks.test.rb

| 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