├── method_proxy.gemspec ├── test └── method_proxy_test.rb ├── README.markdown └── lib └── method_proxy.rb /method_proxy.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'method_proxy' 3 | s.version = '0.2.1' 4 | s.date = '2011-12-01' 5 | s.summary = "Ruby method call interception tool." 6 | s.description = %q(Provides convenient means of "taping" into instance or class method calls. The intercepting 7 | code is provided with reference to the object, reference to the original method and list of arguments.) 8 | s.authors = ["Jevgenij Solovjov"] 9 | s.email = 'jevgenij@kudelabs.com' 10 | s.files = ["lib/method_proxy.rb", "test/method_proxy_test.rb", "README.markdown"] 11 | s.homepage = "https://github.com/kudelabs/method_proxy" 12 | end -------------------------------------------------------------------------------- /test/method_proxy_test.rb: -------------------------------------------------------------------------------- 1 | class GuineaPig 2 | def gp_instance_method a, b 3 | return "#{a}-#{b}" 4 | end 5 | 6 | def gp_instance_method_w_block a, b, &block 7 | interm = "*#{a}*#{b}*" 8 | res = yield interm 9 | res 10 | end 11 | 12 | def self.gp_class_method c, d 13 | return "#{c}$#{d}" 14 | end 15 | 16 | def self.gp_class_method_w_block c, d, &block 17 | interm = "-=#{c}O#{d}=-" 18 | res = yield interm 19 | res 20 | end 21 | end 22 | 23 | require File.join(File.expand_path(File.dirname(__FILE__)), "../lib/method_proxy") 24 | require "test/unit" 25 | 26 | class MethodProxyTest < Test::Unit::TestCase 27 | def test_proxy_unproxy_instance_method 28 | gpig = GuineaPig.new 29 | assert_equal "2-3", gpig.gp_instance_method(2, 3) 30 | assert [].eql?(MethodProxy.classes_with_proxied_instance_methods) 31 | assert_equal [], MethodProxy.proxied_instance_methods_for(GuineaPig) 32 | 33 | MethodProxy.proxy_instance_method(GuineaPig, :gp_instance_method) do |obj, meth, a, b| 34 | res = meth.call a, b 35 | next "<#{res}>" # within Proc's should be 'next' instead of 'return' -- in order to avoid returning from the caller method 36 | end 37 | assert_equal "<2-3>", gpig.gp_instance_method(2, 3) 38 | assert [GuineaPig].eql?(MethodProxy.classes_with_proxied_instance_methods) 39 | assert_equal [:gp_instance_method], MethodProxy.proxied_instance_methods_for(GuineaPig) 40 | 41 | MethodProxy.unproxy_instance_method(GuineaPig, :gp_instance_method) 42 | assert_equal "2-3", gpig.gp_instance_method(2, 3) 43 | assert [].eql?(MethodProxy.classes_with_proxied_instance_methods) 44 | assert_equal [], MethodProxy.proxied_instance_methods_for(GuineaPig) 45 | end 46 | 47 | def test_proxy_unproxy_instance_method_w_block 48 | gpig = GuineaPig.new 49 | before_res = gpig.gp_instance_method_w_block(6, 7) do |interm| 50 | "#{interm}<-->#{interm}" 51 | end 52 | assert [].eql?(MethodProxy.classes_with_proxied_instance_methods) 53 | assert_equal [], MethodProxy.proxied_instance_methods_for(GuineaPig) 54 | assert_equal "*6*7*<-->*6*7*", before_res 55 | 56 | MethodProxy.proxy_instance_method(GuineaPig, :gp_instance_method_w_block) do |obj, meth, a, b, &block| 57 | res = meth.call a, b, &block 58 | next "!!!__#{res}__!!!" # within Proc's should be 'next' instead of 'return' -- in order to avoid returning from the caller method 59 | end 60 | 61 | after_res = gpig.gp_instance_method_w_block(6, 7) do |interm| 62 | "#{interm}<-->#{interm}" 63 | end 64 | 65 | assert_equal "!!!__*6*7*<-->*6*7*__!!!", after_res 66 | assert [GuineaPig].eql?(MethodProxy.classes_with_proxied_instance_methods) 67 | assert_equal [:gp_instance_method_w_block], MethodProxy.proxied_instance_methods_for(GuineaPig) 68 | 69 | MethodProxy.unproxy_instance_method(GuineaPig, :gp_instance_method_w_block) 70 | after_un_res = gpig.gp_instance_method_w_block(6, 7) do |interm| 71 | "#{interm}<-->#{interm}" 72 | end 73 | assert_equal before_res, after_un_res 74 | assert [].eql?(MethodProxy.classes_with_proxied_instance_methods) 75 | assert_equal [], MethodProxy.proxied_instance_methods_for(GuineaPig) 76 | end 77 | 78 | def test_proxy_unproxy_class_method 79 | assert_equal "4$5", GuineaPig.gp_class_method(4, 5) 80 | assert [].eql?(MethodProxy.classes_with_proxied_class_methods) 81 | assert_equal [], MethodProxy.proxied_class_methods_for(GuineaPig) 82 | 83 | MethodProxy.proxy_class_method(GuineaPig, :gp_class_method) do |obj, meth, c, d| 84 | res = meth.call c, d 85 | next "[#{res}]" # within Proc's should be 'next' instead of 'return' -- in order to avoid returning from the caller method 86 | end 87 | assert_equal "[4$5]", GuineaPig.gp_class_method(4, 5) 88 | assert [GuineaPig].eql?(MethodProxy.classes_with_proxied_class_methods) 89 | assert_equal [:gp_class_method], MethodProxy.proxied_class_methods_for(GuineaPig) 90 | 91 | MethodProxy.unproxy_class_method(GuineaPig, :gp_class_method) 92 | assert_equal "4$5", GuineaPig.gp_class_method(4, 5) 93 | assert [].eql?(MethodProxy.classes_with_proxied_class_methods) 94 | assert_equal [], MethodProxy.proxied_class_methods_for(GuineaPig) 95 | end 96 | 97 | def test_proxy_unproxy_class_method_w_block 98 | before_res = GuineaPig.gp_class_method_w_block(8, 9) do |interm| 99 | "#{interm}>--<#{interm}" 100 | end 101 | assert_equal "-=8O9=->--<-=8O9=-", before_res 102 | assert [].eql?(MethodProxy.classes_with_proxied_class_methods) 103 | assert_equal [], MethodProxy.proxied_class_methods_for(GuineaPig) 104 | 105 | MethodProxy.proxy_class_method(GuineaPig, :gp_class_method_w_block) do |obj, meth, a, b, &block| 106 | res = meth.call a, b, &block 107 | next "???__#{res}__???" # within Proc's should be 'next' instead of 'return' -- in order to avoid returning from the caller method 108 | end 109 | 110 | after_res = GuineaPig.gp_class_method_w_block(8, 9) do |interm| 111 | "#{interm}>--<#{interm}" 112 | end 113 | 114 | assert_equal "???__-=8O9=->--<-=8O9=-__???", after_res 115 | assert [GuineaPig].eql?(MethodProxy.classes_with_proxied_class_methods) 116 | assert_equal [:gp_class_method_w_block], MethodProxy.proxied_class_methods_for(GuineaPig) 117 | 118 | MethodProxy.unproxy_class_method(GuineaPig, :gp_class_method_w_block) 119 | after_un_res = GuineaPig.gp_class_method_w_block(8, 9) do |interm| 120 | "#{interm}>--<#{interm}" 121 | end 122 | assert_equal before_res, after_un_res 123 | assert [].eql?(MethodProxy.classes_with_proxied_class_methods) 124 | assert_equal [], MethodProxy.proxied_class_methods_for(GuineaPig) 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | ### WHAT'S THIS? 2 | 3 | 'method_proxy' gem allows to 'tap' into instance method or class method calls 4 | on objects of specific class or on specific classes. 5 | 6 | 7 | ### INSTALLATION 8 | 9 | * `gem install method_proxy` 10 | 11 | 12 | ### API 13 | 14 | * `MethodProxy.proxy_instance_method(SomeClass, :some_instance_method, &block)` 15 | 16 | Replaces original instance method SomeClass#some_instance_method with method 17 | generated from supplied block. Supplied block should expect following 18 | arguments: 19 | 1) object on which the call is made; 20 | 2) original method bound to the object on which call is made; 21 | *args - arguments of original method call; 22 | &block - block passed to the original method call. 23 | 24 | An important thing to remember, is the &block should not use 'return ' 25 | and should use 'next ' construct instead - as 'return' will result in 26 | exception: 27 | 28 | LocalJumpError: unexpected return 29 | 30 | 31 | * `MethodProxy.unproxy_instance_method(SomeClass, :some_instance_method)` 32 | 33 | Restore original instance method SomeClass#some_instance_method. 34 | 35 | 36 | * `MethodProxy.proxy_class_method(SomeClass, :some_class_method, &block)` 37 | 38 | Replaces original class method with method generated from supplied block. 39 | Supplied block should expect following arguments: 40 | 1) class on which the call is made; 41 | 2) original method bound to that class; 42 | *args - arguments of original method call; 43 | &block - block passed to the original method call. 44 | 45 | Just like in case of proxy_instance_method, &block should use 'next ' 46 | construct instead of 'return ' or LocalJumpError will be raised. 47 | 48 | 49 | * `MethodProxy.unproxy_class_method(SomeClass, :some_class_method)` 50 | Restore original class method SomeClass.some_class_method. 51 | 52 | 53 | * `MethodProxy.classes_with_proxied_instance_methods` 54 | 55 | Returns Array of classes that have at least one instance method tapped into by 56 | MethodProxy. 57 | 58 | 59 | * `MethodProxy.proxied_instance_methods_for(klass)` 60 | 61 | Returns Array of instance method names for class klass that have been altered by 62 | MethodProxy. 63 | 64 | 65 | * `MethodProxy.classes_with_proxied_class_methods` 66 | 67 | Returns Array of classes that have at least one class method tapped into by 68 | MethodProxy. 69 | 70 | 71 | * `MethodProxy.proxied_class_methods_for(klass)` 72 | 73 | Returns Array of class method names for class klass that have been altered by 74 | MethodProxy. 75 | 76 | 77 | ### EXAMPLES 78 | 79 | **Example 1.** Non-intrusive debugging. Here's a way to dynamically "inject" call 80 | to debugger before an instance method of interest UsersController#create, with 81 | the help of MethodProxy: 82 | 83 | require 'method_proxy' 84 | require 'ruby-debug' 85 | MethodProxy.proxy_instance_method(UsersController, :create) do |users_controller_obj, orig_create_meth, *args| 86 | debugger 87 | res = orig_create_meth.call *args 88 | next res 89 | end 90 | 91 | 92 | **Example 1b.** Debug on exception: 93 | 94 | require 'method_proxy' 95 | require 'ruby-debug' 96 | MethodProxy.proxy_instance_method(UsersController, :create) do |users_controller_obj, orig_create_meth, *args| 97 | begin 98 | res = orig_create_meth.call *args 99 | rescue Exception => e 100 | debugger 101 | end 102 | next res 103 | end 104 | 105 | 106 | **Example 2.** Automatic recording of system actions during QA process. With the help of MethodProxy one can 107 | arrange interception of actions of interest (say, those that change database state), and storing of the 108 | actions' parameters: 109 | 110 | tbd = { 111 | UsersController => [:create, :update, :delete], 112 | PostsController => [:create, :update, :delete] 113 | } 114 | 115 | tbd.each_pair do |cntrlr, actns| 116 | actns.each do |actn| 117 | MethodProxy.proxy_instance_method(cntrlr, actn) do |controller, meth, *args| 118 | record_hash = Hash.new 119 | record_hash[:controller] = controller.class.name.to_sym 120 | record_hash[:action] = actn 121 | record_hash[:http_meth] = controller.request.method 122 | record_hash[:params] = controller.request.params 123 | 124 | store_action_record(record_hash) # store the information - implement according to your needs! 125 | 126 | meth.call *args 127 | end 128 | end 129 | end 130 | 131 | 132 | Later, the resulting "records" can be "replayed" in automated tests. 133 | 134 | 135 | ### CAVEATS 136 | 137 | There is a number of known issues: 138 | 139 | - have to remember to use "next " instead of "return " syntax; 140 | - if one needs to tap into methods that are dynamically created, e.g. 141 | 'find_user_by_id' created on-the-fly with the help of 'method_missing' in 142 | Rails, the code should make sure that method has been defined before attempting 143 | to tap. 144 | 145 | 146 | ### RELATION TO AOP 147 | 148 | "Tapping" into method calls the way 'method_proxy' does is similar to creating 149 | joint points in Aspect-Oriented Programming, and the same task can be 150 | accomplished with AOP frameworks like Aquarium. 'method_proxy' however is 151 | intended as a simple tool focused on its task - with no attempt to align it 152 | with AOP concepts and terminology. 153 | 154 | 155 | ### LICENSE 156 | 157 | Usage, distribution or modification, for both commercial or non-profit purposes, 158 | are not limited whatsoever. 159 | 160 | 161 | ### DISCLAIMER 162 | 163 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 164 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 165 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 166 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 167 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 168 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 169 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 170 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 171 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 172 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 173 | 174 | 175 | ### CREDITS 176 | Special thanks for their invaluable feedback, advice, suggestions and fixes to: 177 | wtaysom, adevadeh, shaokun and rainchen. 178 | 179 | 180 | ### PROJECT ON THE WEB 181 | 182 | The project is hosted on GitHub: 183 | https://github.com/kudelabs/method_proxy/ 184 | -------------------------------------------------------------------------------- /lib/method_proxy.rb: -------------------------------------------------------------------------------- 1 | require 'thread' if RUBY_VERSION <"1.9" 2 | 3 | class MethodProxyException < Exception 4 | 5 | end 6 | 7 | class MethodProxy 8 | ##################################### CLASS VARIABLES ############################################# 9 | 10 | @@mx = Mutex.new 11 | @@proxied_instance_methods = {} 12 | @@proxied_class_methods = {} 13 | @@tmp_binding = nil 14 | 15 | #################################### SOME HELPER METHODS ########################################## 16 | def self.classes_with_proxied_instance_methods 17 | @@mx.synchronize do 18 | return @@proxied_instance_methods.keys.collect{|k| Class.const_get(k)} 19 | end 20 | end 21 | 22 | def self.proxied_instance_methods_for(klass) 23 | raise "klass argument must be a Class" unless klass.is_a?(Class) || klass.is_a?(Module) 24 | @@mx.synchronize do 25 | meth_hash_for_klass = @@proxied_instance_methods[klass.name.to_sym] 26 | return [] if !meth_hash_for_klass || meth_hash_for_klass.empty? 27 | return meth_hash_for_klass.keys 28 | end 29 | end 30 | 31 | 32 | def self.classes_with_proxied_class_methods 33 | @@mx.synchronize do 34 | return @@proxied_class_methods.keys.collect{|k| Class.const_get(k)} 35 | end 36 | end 37 | 38 | def self.proxied_class_methods_for(klass) 39 | raise "klass argument must be a Class" unless klass.is_a?(Class) || klass.is_a?(Module) 40 | @@mx.synchronize do 41 | meth_hash_for_klass = @@proxied_class_methods[klass.name.to_sym] 42 | return [] if !meth_hash_for_klass || meth_hash_for_klass.empty? 43 | return meth_hash_for_klass.keys 44 | end 45 | end 46 | 47 | 48 | #### WARNING: NON-THREAD-SAFE methods for internal use; generally, they should not be called by #### 49 | #### any external code #### 50 | def self.register_original_instance_method(klass, meth_name, meth_obj) 51 | @@proxied_instance_methods[klass.name.to_sym][meth_name] = meth_obj 52 | end 53 | 54 | def self.original_instance_method(klass, meth_name) 55 | @@proxied_instance_methods[klass.name.to_sym][meth_name] 56 | end 57 | 58 | def self.tmp_binding 59 | @@tmp_binding 60 | end 61 | 62 | protected 63 | def self.capture_tmp_binding!(bndg) 64 | @@tmp_binding = bndg 65 | end 66 | 67 | def self.reset_tmp_binding! 68 | @@tmp_binding = nil 69 | end 70 | 71 | ####################################### MAIN STUFF ################################################# 72 | public 73 | 74 | # "Tap" into instance method calls - subvert original method with the supplied block; preserve 75 | # reference to the original method so that it can still be called or restored later on. 76 | # 77 | # Common idiom: 78 | # MethodProxy.proxy_instance_method(SomeClass, :some_instance_method) do |obj, original_instance_meth, *args, &block| 79 | # 80 | # # do stuff before calling original method 81 | # ... ... ... 82 | # 83 | # # call the original method (already bound to object obj), with supplied arguments 84 | # result = original_instance_meth.call(*args, &block) 85 | # 86 | # # do stuff after calling original method 87 | # ... ... ... 88 | # 89 | # # return the actual return value 90 | # result 91 | # end 92 | def self.proxy_instance_method(klass, meth, &block) 93 | raise "klass argument must be a Class" unless klass.is_a?(Class) || klass.is_a?(Module) 94 | raise "method argument must be a Symbol" unless meth.is_a?(Symbol) 95 | raise "must supply block argument" unless block_given? 96 | 97 | proc = Proc.new(&block) 98 | 99 | @@mx.synchronize do 100 | @@proxied_instance_methods[klass.name.to_sym] ||= {} 101 | if @@proxied_instance_methods[klass.name.to_sym][meth] 102 | raise ::MethodProxyException, "The method has already been proxied" 103 | end 104 | 105 | klass.class_eval do 106 | 107 | MethodProxy.register_original_instance_method(klass, meth, instance_method(meth)) 108 | 109 | undef_method(meth) 110 | 111 | define_method meth do |*args, &blk| 112 | ret = proc.call(self, MethodProxy.original_instance_method(klass, meth).bind(self), *args, &blk) 113 | return ret 114 | end 115 | end 116 | end 117 | end 118 | 119 | # Restore the original instance method for objects of class klass 120 | def self.unproxy_instance_method(klass, meth) 121 | raise "klass argument must be a Class" unless klass.is_a?(Class) || klass.is_a?(Module) 122 | raise "method argument must be a Symbol" unless meth.is_a?(Symbol) 123 | 124 | @@mx.synchronize do 125 | return unless @@proxied_instance_methods[klass.name.to_sym][meth].is_a?(UnboundMethod) # pass-through rather than raise 126 | proc = @@proxied_instance_methods[klass.name.to_sym][meth] 127 | 128 | klass.class_eval{ define_method(meth, proc) } 129 | 130 | # clean up storage 131 | @@proxied_instance_methods[klass.name.to_sym].delete(meth) 132 | remaining_proxied_instance_methods = @@proxied_instance_methods[klass.name.to_sym] 133 | @@proxied_instance_methods.delete(klass.name.to_sym) if remaining_proxied_instance_methods.empty? 134 | end 135 | end 136 | 137 | # "Tap" into class method calls - subvert original method with the supplied block; preserve 138 | # reference to the original method so that it can still be called or restored later on. 139 | # 140 | # Common idiom: 141 | # MethodProxy.proxy_class_method(SomeClass, :some_class_method) do |klass, original_class_meth, *args, &block| 142 | # 143 | # # do stuff before calling original method 144 | # ... ... ... 145 | # 146 | # # call original method (already bound to SomeClass), with supplied arguments 147 | # result = original_class_meth.call(*args, &block) 148 | # 149 | # # do stuff after calling original method 150 | # ... ... ... 151 | # 152 | # # return the actual return value 153 | # result 154 | # end 155 | def self.proxy_class_method klass, meth, &block 156 | raise "klass argument must be a Class" unless klass.is_a?(Class) || klass.is_a?(Module) 157 | raise "method argument must be a Symbol" unless meth.is_a?(Symbol) 158 | raise "must supply block argument" unless block_given? 159 | 160 | @@mx.synchronize do 161 | proc = Proc.new(&block) 162 | 163 | capture_tmp_binding! binding 164 | 165 | @@proxied_class_methods[klass.name.to_sym] ||= {} 166 | 167 | class << klass 168 | 169 | klass, meth, proc = eval "[klass, meth, proc]", MethodProxy.tmp_binding 170 | 171 | self.instance_eval do 172 | if @@proxied_class_methods[klass.name.to_sym][meth] 173 | raise ::MethodProxyException, "The method has already been proxied" 174 | end 175 | @@proxied_class_methods[klass.name.to_sym][meth] = instance_method meth 176 | end 177 | 178 | remove_method meth 179 | 180 | self.instance_eval do 181 | define_method(meth) do |*args, &blk| 182 | ret = proc.call(self, @@proxied_class_methods[klass.name.to_sym][meth].bind(self), *args, &blk) 183 | return ret 184 | end 185 | end 186 | end 187 | 188 | reset_tmp_binding! 189 | end 190 | end 191 | 192 | # Restore the original class method for klass 193 | def self.unproxy_class_method klass, meth 194 | raise "klass argument must be a Class" unless klass.is_a?(Class) || klass.is_a?(Module) 195 | raise "method argument must be a Symbol" unless meth.is_a?(Symbol) 196 | 197 | @@mx.synchronize do 198 | return unless (class_entries = @@proxied_class_methods[klass.name.to_sym]) 199 | return unless (orig_unbound_meth = class_entries[meth]) 200 | 201 | capture_tmp_binding! binding 202 | 203 | class << klass 204 | meth, orig_unbound_meth = eval "[meth, orig_unbound_meth]", MethodProxy.tmp_binding 205 | self.instance_eval do 206 | define_method meth, orig_unbound_meth 207 | end 208 | end 209 | 210 | reset_tmp_binding! 211 | 212 | # clean up storage 213 | @@proxied_class_methods[klass.name.to_sym].delete(meth) 214 | remaining_proxied_class_methods = @@proxied_class_methods[klass.name.to_sym] 215 | @@proxied_class_methods.delete(klass.name.to_sym) if remaining_proxied_class_methods.empty? 216 | end 217 | end 218 | end 219 | --------------------------------------------------------------------------------