├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── resugan.rb └── resugan │ ├── context.rb │ ├── engine │ ├── inline_dispatcher.rb │ └── marshalled_inline_dispatcher.rb │ ├── kernel.rb │ ├── object.rb │ ├── object_helpers.rb │ ├── thread.rb │ └── version.rb ├── resugan.gemspec └── spec ├── resugan_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.gem 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.3.1 5 | before_install: gem install bundler -v 1.12.3 6 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | = Version 0.1.14 2 | * events will only be resolved at the top level for nested resugan blocks unless resugan! is used 3 | * Config is set using Resugan::Kernel.config do |c| c..... end 4 | 5 | = Version 0.1.11 6 | * Allow listeners to attach to multiple namespaces to allow for DRY'er code 7 | 8 | = Version 0.1.8 9 | * First real version for public consumption 10 | 11 | = Version 0.1 12 | * Initial Version 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at joseph.dayo@gmail.com. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in resugan.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Joseph Emmanuel Dayo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/resugan.svg)](https://badge.fury.io/rb/resugan) [![CircleCI](https://circleci.com/gh/jedld/resugan.svg?style=svg)](https://circleci.com/gh/jedld/resugan) 2 | 3 | # Resugan 4 | 5 | Simple, powerful and unobstrusive event driven architecture framework for ruby. This gem provides 6 | a base framework in order to build a more powerful event based system on top of it. Events cuts across multiple objects and allows you to cleanly separate business logic to other cross cutting concerns like analytics and logging. Multiple events are consolidated allowing you to efficiently batch related operations together. 7 | 8 | Also allows for a customizable backend which enables the use of various evented queuing mechanisms 9 | like redis queue, amazon SQS with minimal changes to your code that generates the events. 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'resugan' 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install resugan 26 | 27 | ## Basic Usage 28 | 29 | Register listeners, using : 30 | 31 | ```ruby 32 | _listener :event1 do |array_of_params| 33 | puts "hello! event 2 has been called!" 34 | end 35 | 36 | _listener :hay do |array_of_params| 37 | puts "hello! someone said hay!" 38 | end 39 | ``` 40 | 41 | Listeners are basically code that listens to an event, in this case :event1 and :hay. 42 | an array of params equal to the number of times that specific event was captured 43 | will be passed. So if :event1 was called twice, array_of_params will contain 2 elements. 44 | 45 | Generate events using _fire and wrap them in a resugan block: 46 | 47 | ```ruby 48 | resugan { 49 | puts "I am now going to generate an event" 50 | 51 | _fire :event2 52 | 53 | _fire :hay 54 | 55 | _fire :bam, { some_param: 'param' } # you can pass hashes to add meta to the event 56 | } 57 | ``` 58 | 59 | The _fire method is available inside all objects, however the events won't be 60 | collected unless within the context of a resugan block. The idea is that you 61 | can prepackage a library that fires those events but won't actually 62 | get used until someone specifically listens for it. 63 | 64 | Note that events don't have to be fired at the top level of the block, even objects 65 | used inside the block can invoke fire to generate an event. 66 | 67 | The two events should fire and should print: 68 | 69 | ```ruby 70 | hello! event 2 has been called! 71 | hello! someone said hay! 72 | ``` 73 | 74 | Note that your listener will be executed once even if an event has been fired 75 | multiple times. However params will contain the payload of both events. This allows you to batch together requests and efficiently dispatch them as a group. 76 | 77 | # Object helpers 78 | 79 | Helpers are available to make listening firing events a little bit cleaner: 80 | 81 | ```ruby 82 | class TestObject 83 | include Resugan::ObjectHelpers 84 | end 85 | ``` 86 | 87 | This basically allows for the attach_hook to be available 88 | 89 | ```ruby 90 | class TestObject 91 | include Resugan::ObjectHelpers 92 | 93 | def method2 94 | _fire :event1 95 | end 96 | 97 | def method3 98 | _fire :event2, param1: "hello" 99 | end 100 | 101 | attach_hook :method2 102 | attach_hook :method3, namespace: "namespace1" 103 | end 104 | ``` 105 | 106 | What this does is it essentially wraps the specified methods inside a resugan block. 107 | 108 | Please see spec/resugan_spec.rb for more examples and details. 109 | 110 | ## namespaces 111 | 112 | Resugan supports namespaces, allowing you to group listeners and trigger them separately 113 | 114 | ```ruby 115 | _listener :event1, namespace: "group1" do |array_of_params| 116 | puts "hello! event 2 has been called!" 117 | end 118 | 119 | _listener :event1, namespace: "group2" do |array_of_params| 120 | puts "hello! someone said hay!" 121 | end 122 | 123 | _listener :log, namespace: %w(group1 group2) do |array_of_params| 124 | array_of_params.each { 125 | puts "listener that belongs to 2 namespaces" 126 | } 127 | end 128 | 129 | resugan "group1" do 130 | _fire :event1 131 | _fire :log 132 | end 133 | 134 | resugan "group2" do 135 | _fire :event1 136 | _fire :log 137 | end 138 | ``` 139 | 140 | Behavior is as expected. Events under group1 will only be handled by listeners under group1 and so on. 141 | 142 | The above should print: 143 | 144 | ``` 145 | hello! event 2 has been called! 146 | listener that belongs to 2 namespaces 147 | hello! someone said hay! 148 | listener that belongs to 2 namespaces 149 | ``` 150 | 151 | ### Behavior of nested resugan blocks 152 | 153 | Resugan will by default only resolve events at the outermost resugan context of 154 | a namespace. 155 | 156 | ```ruby 157 | _listener :event1 do |array_of_params| 158 | puts "hello! event 1" 159 | end 160 | 161 | _listener :event2, namespace: "group2" do |array_of_params| 162 | puts "hello! event 1" 163 | end 164 | 165 | resugan { 166 | _fire :event1 167 | 168 | resugan { 169 | _fire :event2 170 | } 171 | } 172 | ``` 173 | 174 | If there are nested resugan blocks note that event dispatch will occur at the 175 | outermost context. Output will be: 176 | 177 | ``` 178 | hello! event 1 179 | hello! event 2 180 | ``` 181 | 182 | To force the innermost block to immediately resolve, use resugan! instead (or set Kernel.config.reuse_top_level_context = false globally): 183 | 184 | ```ruby 185 | resugan { 186 | _fire :event1 187 | 188 | resugan! { 189 | _fire :event2 190 | } 191 | } 192 | ``` 193 | 194 | Since the inner block will be resolved first, Output will be: 195 | 196 | ``` 197 | hello! event 2 198 | hello! event 1 199 | ``` 200 | 201 | ## Unique Listeners 202 | 203 | the _listener always creates a new listener for an event, so if it so happens that 204 | the code that creates those listeners gets executed again it will create another one. 205 | if you want to make sure that listener only gets executed once you can pass an id 206 | option: 207 | 208 | ```ruby 209 | _listener :event1, id: 'no_other_listener_like_this' do |array| 210 | # some code that gets executed 211 | end 212 | ``` 213 | 214 | Or you can use the _listener! form which make sure a certain block is limited to 215 | only a single instance. 216 | 217 | ```ruby 218 | 2.times do |i| 219 | _listener! :event1 do |array| 220 | # There will be only one instance of this listener no matter how many times it is defined 221 | end 222 | end 223 | ``` 224 | 225 | ## Customizing the Event dispatcher 226 | 227 | The way events are consumed is entirely customizable. You can register your own event dispatcher: 228 | 229 | ```ruby 230 | class MyCustomerDispatcher 231 | def dispatch(namespace, events) 232 | events.each do |k,v| 233 | Resugan::Kernel.invoke(namespace, k, v) 234 | end 235 | end 236 | end 237 | ``` 238 | 239 | You need to implement your own dispatch method, captured events are passed as 240 | parameters. 241 | 242 | You can then set it as the default dispatcher: 243 | 244 | ```ruby 245 | Resugan::Kernel.set_default_dispatcher(MyCustomerDispatcher) 246 | ``` 247 | 248 | Or assign it to a specific namespace: 249 | 250 | ```ruby 251 | Resugan::Kernel.register_dispatcher(MyCustomerDispatcher, 'CustomGroup') 252 | ``` 253 | 254 | This allows you to use various queue backends per namespace, like resugan-worker for example. 255 | 256 | ### Debugging 257 | 258 | Sometimes you need to track where events are fired. You can do so by enabling line tracing: 259 | 260 | ```ruby 261 | Resugan::Kernel.config do |c| 262 | c.line_trace_enabled = true 263 | end 264 | ``` 265 | 266 | Line source should now be passed as params everytime you fire an event. You can also 267 | view it by dumping a resugan context. 268 | 269 | ```ruby 270 | puts(resugan { 271 | _fire :event1 272 | }.dump) 273 | ``` 274 | 275 | ```ruby 276 | {:event1=>[{:params=>{:_source=>"/Users/jedld/workspace/resugan/spec/resugan_spec.rb:144:in `block (5 levels) in '"}}]} 277 | ``` 278 | 279 | ### Using Resugan::Engine::MarshalledInlineDispatcher 280 | 281 | By default, resugan uses the Resugan::Engine::InlineDispatcher as the default dispatcher for 282 | all namespaces. For performance reasons, params passed to the _fire method are passed as is, but there are 283 | times when you want to simulate params that are passed using JSON.parse as is the case 284 | when using a custom dispatcher that uses redis (see resugan-worker). In this case you may set MarshalledInlineDispatcher 285 | as the default dispatcher for test and development environment instead (e.g. rails): 286 | 287 | ```ruby 288 | Resugan::Kernel.set_default_dispatcher(Resugan::Engine::MarshalledInlineDispatcher) if Rails.env.development? || Rails.env.test? 289 | ``` 290 | 291 | Related Projects 292 | ================= 293 | 294 | Below are projects that extend resugan. 295 | 296 | ### Resugan Worker 297 | 298 | A project that wraps resugan listeners to be consumed using an external worker. Think of this as a redis queue backend. 299 | Can also be used as a sample on how to extend resugan. 300 | 301 | https://github.com/jedld/resugan-worker 302 | 303 | ### Testing 304 | 305 | RSpec helpers are available: 306 | 307 | https://github.com/jedld/resugan-rspec 308 | 309 | ## Similar Projects 310 | 311 | wisper (https://github.com/krisleech/wisper) - An excellent gem that focuses on a coupled pub-sub model. Though its global listeners somehow have the same effect though in a syntactically different way. 312 | 313 | event_bus (https://github.com/kevinrutherford/event_bus) - Loosely coupled pub-sub similar to resugan 314 | 315 | ## Development 316 | 317 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 318 | 319 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 320 | 321 | ## Contributing 322 | 323 | Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/resugan. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 324 | 325 | 326 | ## License 327 | 328 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 329 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "resugan" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/resugan.rb: -------------------------------------------------------------------------------- 1 | require "resugan/version" 2 | require "resugan/kernel" 3 | require "resugan/object" 4 | require "resugan/thread" 5 | require "resugan/context" 6 | require "resugan/object_helpers" 7 | require "resugan/engine/inline_dispatcher" 8 | require "resugan/engine/marshalled_inline_dispatcher" 9 | require "json" 10 | 11 | module Resugan 12 | end 13 | -------------------------------------------------------------------------------- /lib/resugan/context.rb: -------------------------------------------------------------------------------- 1 | module Resugan 2 | class Context 3 | def initialize(namespace = '') 4 | @namespace = namespace.to_s 5 | @events = {} 6 | end 7 | 8 | def namespace 9 | @namespace 10 | end 11 | 12 | def register(event, params = {}) 13 | event = event.to_sym 14 | payload = { params: params } 15 | if @events[event] 16 | @events[event] << payload 17 | else 18 | @events[event] = [payload] 19 | end 20 | end 21 | 22 | def invoke 23 | dispatcher = Resugan::Kernel.dispatcher_for(@namespace) 24 | dispatcher.dispatch(@namespace, @events) 25 | end 26 | 27 | def dump 28 | @events 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/resugan/engine/inline_dispatcher.rb: -------------------------------------------------------------------------------- 1 | module Resugan 2 | module Engine 3 | class InlineDispatcher 4 | def dispatch(namespace, events) 5 | events.each do |k,v| 6 | Resugan::Kernel.invoke(namespace, k, v) 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/resugan/engine/marshalled_inline_dispatcher.rb: -------------------------------------------------------------------------------- 1 | module Resugan 2 | module Engine 3 | class MarshalledInlineDispatcher 4 | def dispatch(namespace, events) 5 | marshalled_events = [] 6 | events.each do |k, v| 7 | marshalled_events << { event: k, args: v }.to_json 8 | end 9 | 10 | marshalled_events.each do |event| 11 | unmarshalled_event = JSON.parse(event) 12 | Resugan::Kernel.invoke(namespace, unmarshalled_event['event'], unmarshalled_event['args']) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/resugan/kernel.rb: -------------------------------------------------------------------------------- 1 | module Resugan 2 | class Config 3 | attr_accessor :reuse_top_level_context, :warn_no_context_events, :line_trace_enabled, :default_dispatcher 4 | 5 | def initialize 6 | @reuse_top_level_context = true 7 | @warn_no_context_events = false 8 | @line_trace_enabled = false 9 | @default_dispatcher = Resugan::Engine::InlineDispatcher 10 | end 11 | end 12 | 13 | class Kernel 14 | def self.config 15 | @config ||= Resugan::Config.new 16 | if block_given? 17 | yield @config 18 | end 19 | 20 | @config 21 | end 22 | 23 | def self.reuse_top_level_context? 24 | config.reuse_top_level_context 25 | end 26 | 27 | def self.warn_no_context_events? 28 | config.warn_no_context_events 29 | end 30 | 31 | def self.line_trace_enabled? 32 | config.line_trace_enabled 33 | end 34 | 35 | def self.set_default_dispatcher(dispatcher) 36 | config.default_dispatcher = dispatcher 37 | end 38 | 39 | def self.default_dispatcher 40 | config.default_dispatcher 41 | end 42 | 43 | def self.dispatcher_for(namespace = '') 44 | @dispatchers = {} unless @dispatchers 45 | @dispatchers[namespace.to_s] || default_dispatcher.new 46 | end 47 | 48 | def self.register_dispatcher(dispatcher, namespace = '') 49 | @dispatchers = {} unless @dispatchers 50 | @dispatchers[namespace.to_s] = (dispatcher.is_a?(Class) ? dispatcher.new : dispatcher) 51 | end 52 | 53 | def self.register(event, &block) 54 | register_with_namespace("", event, block) 55 | end 56 | 57 | def self.register_with_namespace(namespaces, event_type, listener_id = nil, block) 58 | @listener_ids = {} unless @listener_ids 59 | @_listener = {} unless @_listener 60 | 61 | namespaces = namespaces.is_a?(Array) ? namespaces : [namespaces] 62 | namespaces.each do |n| 63 | next if listener_id && @listener_ids["#{n}_#{listener_id}"] 64 | 65 | event = "#{n}_#{event_type}".to_sym 66 | 67 | unless @_listener[event] 68 | @_listener[event] = [block] 69 | else 70 | @_listener[event] << block 71 | end 72 | 73 | @listener_ids["#{n}_#{listener_id}"] = block if listener_id 74 | end 75 | 76 | self 77 | end 78 | 79 | def self.invoke(namespace, event, payload = []) 80 | event = "#{namespace}_#{event}".to_sym 81 | if @_listener && @_listener[event] 82 | @_listener[event].each do |_listener| 83 | _listener.call(payload.map { |p| p[:params] || p['params'] }) 84 | end 85 | end 86 | end 87 | 88 | def self.listeners 89 | @_listener 90 | end 91 | 92 | def self.clear 93 | @listener_ids.clear if @listener_ids 94 | @_listener.clear if @_listener 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/resugan/object.rb: -------------------------------------------------------------------------------- 1 | class Object 2 | def resugan(namespace = '', &block) 3 | namespace ||= '' 4 | current_thread = Thread.current 5 | current_thread.push_resugan_context(namespace) 6 | begin 7 | block.call 8 | ensure 9 | context = current_thread.pop_resugan_context 10 | end 11 | context 12 | end 13 | 14 | def resugan!(namespace = '', &block) 15 | namespace ||= '' 16 | current_thread = Thread.current 17 | current_thread.push_resugan_context(namespace, true) 18 | begin 19 | block.call 20 | ensure 21 | context = current_thread.pop_resugan_context(true) 22 | end 23 | context 24 | end 25 | 26 | def _fire(event, params = {}) 27 | params[:_source] = caller[0] if Resugan::Kernel.line_trace_enabled? 28 | 29 | current_thread = Thread.current 30 | if current_thread.resugan_context 31 | current_thread.resugan_context.register(event, params) 32 | else 33 | puts "WARN: #{event} called in #{caller[0]} but was not inside a resugan {} block" if Resugan::Kernel.warn_no_context_events? 34 | end 35 | end 36 | 37 | def _listener(event, options = {}, &block) 38 | Resugan::Kernel.register_with_namespace(options[:namespace], event, options[:id], ->(params) { 39 | block.call(params) 40 | }) 41 | end 42 | 43 | def _listener!(event, options = {}, &block) 44 | Resugan::Kernel.register_with_namespace(options[:namespace], event, options[:id] || caller[0], ->(params) { 45 | block.call(params) 46 | }) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/resugan/object_helpers.rb: -------------------------------------------------------------------------------- 1 | module Resugan 2 | module ObjectHelpers 3 | def self.included base 4 | base.extend ClassMethods 5 | end 6 | 7 | module ClassMethods 8 | def attach_hook(method, options = {}) 9 | alias_method "_resugan_orig_#{method}".to_sym, method.to_sym 10 | 11 | define_method(method.to_sym) do |*args| 12 | resugan options[:namespace] do 13 | send("_resugan_orig_#{method}".to_sym, *args) 14 | end 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/resugan/thread.rb: -------------------------------------------------------------------------------- 1 | class Thread 2 | def push_resugan_context(namespace = '', force_invoke = false) 3 | @resugan_context_stack ||= [] 4 | 5 | namespace = namespace.to_s 6 | 7 | if @resugan_context.nil? || !Resugan::Kernel.reuse_top_level_context? || force_invoke 8 | @resugan_context = Resugan::Context.new(namespace) 9 | elsif @resugan_context.namespace != namespace 10 | @resugan_context = (@resugan_context_stack.reverse.find { |e| e.namespace == namespace }) || Resugan::Context.new(namespace) 11 | end 12 | 13 | @resugan_context_stack << @resugan_context 14 | end 15 | 16 | def pop_resugan_context(force_invoke = false) 17 | _resugan_context = @resugan_context_stack.pop 18 | @resugan_context = @resugan_context_stack.last 19 | 20 | # depending on option, only invoke if top level 21 | if !force_invoke && Resugan::Kernel.reuse_top_level_context? 22 | _resugan_context.invoke if @resugan_context_stack.find { |e| e.namespace == _resugan_context.namespace }.nil? 23 | elsif 24 | _resugan_context.invoke 25 | end 26 | 27 | _resugan_context 28 | end 29 | 30 | def resugan_context 31 | @resugan_context 32 | end 33 | 34 | private 35 | 36 | def clear_context 37 | @resugan_context_stack = [] 38 | @resugan_context 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/resugan/version.rb: -------------------------------------------------------------------------------- 1 | module Resugan 2 | VERSION = "0.1.15" 3 | end 4 | -------------------------------------------------------------------------------- /resugan.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'resugan/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "resugan" 8 | spec.version = Resugan::VERSION 9 | spec.authors = ["Joseph Emmanuel Dayo"] 10 | spec.email = ["joseph.dayo@gmail.com"] 11 | 12 | spec.summary = %q{ simple, powerful and unobstrusive event framework for ruby } 13 | spec.description = %q{ simple, powerful and unobstrusive event framework for ruby } 14 | spec.homepage = "https://github.com/jedld/resugan" 15 | spec.license = "MIT" 16 | 17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 18 | # to allow pushing to a single host or delete this section to allow pushing to any host. 19 | if spec.respond_to?(:metadata) 20 | spec.metadata['allowed_push_host'] = "https://rubygems.org" 21 | else 22 | raise "RubyGems 2.0 or newer is required to protect against public gem pushes." 23 | end 24 | 25 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 26 | spec.bindir = "exe" 27 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 28 | spec.require_paths = ["lib"] 29 | 30 | spec.add_development_dependency "bundler" 31 | spec.add_development_dependency "rake", "~> 10.0" 32 | spec.add_development_dependency "rspec", "~> 3.0" 33 | spec.add_development_dependency "pry" 34 | end 35 | -------------------------------------------------------------------------------- /spec/resugan_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class TestObject 4 | include Resugan::ObjectHelpers 5 | 6 | def method1(params) 7 | end 8 | 9 | def methodx(params) 10 | end 11 | 12 | def method2 13 | _fire :event1 14 | end 15 | 16 | def method3 17 | _fire :event2, param1: "hello" 18 | end 19 | 20 | attach_hook :method2 21 | attach_hook :method3, namespace: "namespace1" 22 | end 23 | 24 | module Resugan 25 | module Engine 26 | class CustomDispatcher 27 | def dispatch(namespace, events) 28 | events.collect do |k,v| 29 | "#{k},#{v}" 30 | end 31 | end 32 | end 33 | end 34 | end 35 | 36 | describe Resugan do 37 | before :each do 38 | Resugan::Kernel.clear 39 | Resugan::Kernel.set_default_dispatcher Resugan::Engine::MarshalledInlineDispatcher 40 | Thread.current.send(:clear_context) 41 | end 42 | 43 | it 'captures _fire calls' do 44 | _listener :event2 do |params| 45 | TestObject.new.method1(params) 46 | end 47 | 48 | _listener :event3 do |params| 49 | TestObject.new.methodx(params) 50 | end 51 | 52 | expect_any_instance_of(TestObject).to receive(:method1) 53 | expect_any_instance_of(TestObject).to receive(:methodx) 54 | 55 | resugan { 56 | _fire :event1 57 | _fire :event1 58 | _fire :event2, param1: "hello" 59 | _fire "event3" # string or symbol doesn't really matter 60 | } 61 | end 62 | 63 | it 'supports method hooks' do 64 | _listener :event1 do |params| 65 | TestObject.new.method1(params) 66 | end 67 | 68 | _listener :event2, namespace: "namespace1" do |params| 69 | TestObject.new.methodx(params) 70 | expect(params[0]['param1']).to eq "hello" 71 | end 72 | 73 | expect_any_instance_of(TestObject).to receive(:method1) 74 | expect_any_instance_of(TestObject).to receive(:methodx) 75 | 76 | TestObject.new.method2 77 | TestObject.new.method3 78 | end 79 | 80 | context "namespaces" do 81 | it 'supports multiple namespaces' do 82 | _listener :event2, namespace: "namespace1" do |params| 83 | TestObject.new.method1(params) 84 | expect(params[0]['param1']).to eq "hello" 85 | end 86 | 87 | expect_any_instance_of(TestObject).to receive(:method1) 88 | 89 | resugan "namespace1" do 90 | _fire :event1 91 | _fire :event3 92 | _fire :event2, param1: "hello" 93 | end 94 | end 95 | 96 | it 'supports multiple namespaces per listener' do 97 | @counter = 0 98 | _listener :event2, namespace: ['namespace1', 'namespace2'] do |params| 99 | params.each { @counter += 1 } 100 | end 101 | 102 | resugan("namespace1") do 103 | _fire :event2, param1: "hello" 104 | end 105 | 106 | resugan("namespace2") do 107 | _fire :event2, param2: "Hi" 108 | end 109 | 110 | resugan { _fire :event2 } 111 | 112 | expect(@counter).to eq 2 113 | end 114 | 115 | context "nested resugan blocks" do 116 | it "Only dispatches at the top level namespace by default" do 117 | counter = 0 118 | 119 | _listener :event1 do |params| 120 | params.each { counter += 1 } 121 | end 122 | 123 | resugan { 124 | _fire :event1 125 | 126 | resugan { 127 | _fire :event1 128 | _fire :event1 129 | } 130 | expect(counter).to eq 0 131 | } 132 | 133 | counter = 0 134 | 135 | resugan { 136 | _fire :event1 137 | 138 | resugan { 139 | _fire :event1 140 | _fire :event1 141 | 142 | resugan! { 143 | _fire :event1 144 | } 145 | 146 | expect(counter).to eq 1 147 | } 148 | 149 | expect(counter).to eq 1 150 | } 151 | 152 | expect(counter).to eq 4 153 | end 154 | 155 | it "dispatches immediately at the end of the block if reuse_top_level_context = false" do 156 | Resugan::Kernel.config do |c| 157 | c.reuse_top_level_context = false 158 | end 159 | 160 | counter = 0 161 | 162 | _listener :event1 do |params| 163 | params.each { counter += 1 } 164 | end 165 | 166 | resugan { 167 | _fire :event1 168 | 169 | resugan { 170 | _fire :event1 171 | _fire :event1 172 | } 173 | 174 | expect(counter).to eq 2 175 | } 176 | expect(counter).to eq 3 177 | 178 | Resugan::Kernel.config do |c| 179 | c.reuse_top_level_context = true 180 | end 181 | end 182 | end 183 | end 184 | 185 | it 'supports multiple hooks on one event' do 186 | expect_any_instance_of(TestObject).to receive(:method1) 187 | expect_any_instance_of(TestObject).to receive(:methodx) 188 | 189 | _listener :event1 do |params| 190 | TestObject.new.method1(params) 191 | end 192 | 193 | _listener :event1 do |params| 194 | TestObject.new.methodx(params) 195 | end 196 | 197 | resugan { 198 | _fire :event1 199 | } 200 | end 201 | 202 | it 'if id is given, _listener with that id is only allowed to be registered once' do 203 | _listener :event1, id: 'cat' do |params| 204 | TestObject.new.method1(params) 205 | end 206 | 207 | _listener :event1, id: 'cat' do |params| 208 | TestObject.new.methodx(params) 209 | fail 210 | end 211 | 212 | # _listener! prevents a block from being defined twice 213 | count = 0 214 | 2.times do |i| 215 | _listener! :event2 do |params| 216 | count += 1 217 | end 218 | end 219 | 220 | expect_any_instance_of(TestObject).to receive(:method1) 221 | 222 | resugan { 223 | _fire :event1 224 | _fire :event2 225 | } 226 | 227 | expect(count).to eq(1) 228 | end 229 | 230 | context "behavior of return and exceptions" do 231 | it "Always ensures that events are consumed even if the block returns" do 232 | @must_be_true = false 233 | 234 | _listener :event1, id: 'xxx' do |params| 235 | @must_be_true = true 236 | end 237 | 238 | begin 239 | resugan { 240 | _fire :event1 241 | raise "error" 242 | } 243 | 244 | expect(true).to eq false 245 | rescue RuntimeError 246 | end 247 | 248 | expect(@must_be_true).to be 249 | end 250 | end 251 | 252 | context "customizations" do 253 | it "allow the default dispatcher to be modified" do 254 | Resugan::Kernel.register_dispatcher(Resugan::Engine::CustomDispatcher, "namespacex") 255 | 256 | expect_any_instance_of(Resugan::Engine::CustomDispatcher).to receive(:dispatch).with('namespacex', 257 | {:event1=>[{:params=>{}}, {:params=>{}}], :event2=>[{:params=>{:param1=>"hello"}}]}) 258 | 259 | resugan "namespacex" do 260 | _fire :event1 261 | _fire :event1 262 | _fire :event2, param1: "hello" 263 | end 264 | end 265 | 266 | context "debugging" do 267 | around :each do |example| 268 | Resugan::Kernel.config do |c| 269 | c.line_trace_enabled = true 270 | end 271 | example.run 272 | Resugan::Kernel.config do |c| 273 | c.line_trace_enabled = false 274 | end 275 | end 276 | 277 | it "a resugan block returns a context which can be dumped" do 278 | context_dump = resugan { 279 | _fire :event1 280 | }.dump 281 | 282 | expect(context_dump[:event1].size).to eq 1 283 | end 284 | 285 | it "allows line source tracing to be enabled" do 286 | context_dump = resugan { 287 | _fire :event1 288 | _fire :event1 289 | _fire :event2, param1: "hello" 290 | }.dump 291 | 292 | expect(context_dump.size).to eq 2 #two events 293 | expect(context_dump[:event2].first[:params][:_source]).to match /spec\/resugan_spec\.rb\:/ 294 | end 295 | end 296 | end 297 | end 298 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'resugan' 3 | --------------------------------------------------------------------------------