wisper-compat 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +19 -0
  3. data/.gitignore +20 -0
  4. data/.rspec +4 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +152 -0
  7. data/CONTRIBUTING.md +57 -0
  8. data/Gemfile +10 -0
  9. data/README.md +358 -0
  10. data/Rakefile +6 -0
  11. data/lib/wisper/broadcasters/logger_broadcaster.rb +41 -0
  12. data/lib/wisper/broadcasters/send_broadcaster.rb +9 -0
  13. data/lib/wisper/configuration.rb +44 -0
  14. data/lib/wisper/global_listeners.rb +72 -0
  15. data/lib/wisper/publisher.rb +89 -0
  16. data/lib/wisper/registration/block.rb +11 -0
  17. data/lib/wisper/registration/object.rb +43 -0
  18. data/lib/wisper/registration/registration.rb +18 -0
  19. data/lib/wisper/temporary_listeners.rb +41 -0
  20. data/lib/wisper/value_objects/events.rb +61 -0
  21. data/lib/wisper/value_objects/prefix.rb +29 -0
  22. data/lib/wisper/version.rb +3 -0
  23. data/lib/wisper.rb +65 -0
  24. data/spec/lib/global_listeners_spec.rb +82 -0
  25. data/spec/lib/integration_spec.rb +56 -0
  26. data/spec/lib/simple_example_spec.rb +21 -0
  27. data/spec/lib/temporary_global_listeners_spec.rb +103 -0
  28. data/spec/lib/wisper/broadcasters/logger_broadcaster_spec.rb +129 -0
  29. data/spec/lib/wisper/broadcasters/send_broadcaster_spec.rb +68 -0
  30. data/spec/lib/wisper/configuration/broadcasters_spec.rb +11 -0
  31. data/spec/lib/wisper/configuration_spec.rb +36 -0
  32. data/spec/lib/wisper/publisher_spec.rb +311 -0
  33. data/spec/lib/wisper/registrations/object_spec.rb +14 -0
  34. data/spec/lib/wisper/value_objects/events_spec.rb +107 -0
  35. data/spec/lib/wisper/value_objects/prefix_spec.rb +46 -0
  36. data/spec/lib/wisper_spec.rb +99 -0
  37. data/spec/spec_helper.rb +21 -0
  38. data/wisper-compat.gemspec +29 -0
  39. metadata +102 -0
@@ -0,0 +1,44 @@
1
+ require 'forwardable'
2
+
3
+ module Wisper
4
+ class Configuration
5
+ attr_reader :broadcasters
6
+
7
+ def initialize
8
+ @broadcasters = Broadcasters.new
9
+ end
10
+
11
+ # registers a broadcaster, referenced by key
12
+ #
13
+ # @param key [String, #to_s] an arbitrary key
14
+ # @param broadcaster [#broadcast] a broadcaster
15
+ def broadcaster(key, broadcaster)
16
+ @broadcasters[key] = broadcaster
17
+ self
18
+ end
19
+
20
+ # sets the default value for prefixes
21
+ #
22
+ # @param [#to_s] value
23
+ #
24
+ # @return [String]
25
+ def default_prefix=(value)
26
+ ValueObjects::Prefix.default = value
27
+ end
28
+
29
+ class Broadcasters
30
+ extend Forwardable
31
+
32
+ def_delegators :@data, :[], :[]=, :empty?, :include?, :clear, :keys, :to_h
33
+
34
+ def initialize
35
+ @data = {}
36
+ end
37
+
38
+ def fetch(key)
39
+ raise KeyError, "broadcaster not found for #{key}" unless include?(key)
40
+ @data[key]
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,72 @@
1
+ # Handles global subscriptions
2
+
3
+ # @api private
4
+
5
+ require 'singleton'
6
+
7
+ module Wisper
8
+ class GlobalListeners
9
+ include Singleton
10
+
11
+ def initialize
12
+ @registrations = Set.new
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def subscribe(*listeners, **options)
17
+ with_mutex do
18
+ listeners.each do |listener|
19
+ @registrations << ObjectRegistration.new(listener, **options)
20
+ end
21
+ end
22
+ self
23
+ end
24
+
25
+ def unsubscribe(*listeners)
26
+ with_mutex do
27
+ @registrations.delete_if do |registration|
28
+ listeners.include?(registration.listener)
29
+ end
30
+ end
31
+ self
32
+ end
33
+
34
+ def registrations
35
+ with_mutex { @registrations }
36
+ end
37
+
38
+ def listeners
39
+ registrations.map(&:listener).freeze
40
+ end
41
+
42
+ def clear
43
+ with_mutex { @registrations.clear }
44
+ end
45
+
46
+ def self.subscribe(*listeners, **options)
47
+ instance.subscribe(*listeners, **options)
48
+ end
49
+
50
+ def self.unsubscribe(*listeners)
51
+ instance.unsubscribe(*listeners)
52
+ end
53
+
54
+ def self.registrations
55
+ instance.registrations
56
+ end
57
+
58
+ def self.listeners
59
+ instance.listeners
60
+ end
61
+
62
+ def self.clear
63
+ instance.clear
64
+ end
65
+
66
+ private
67
+
68
+ def with_mutex
69
+ @mutex.synchronize { yield }
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,89 @@
1
+ module Wisper
2
+ module Publisher
3
+ def listeners
4
+ registrations.map(&:listener).freeze
5
+ end
6
+
7
+ # subscribe a listener
8
+ #
9
+ # @example
10
+ # my_publisher.subscribe(MyListener.new)
11
+ #
12
+ # @return [self]
13
+ def subscribe(listener, **options)
14
+ raise ArgumentError, "#{__method__} does not take a block, did you mean to call #on instead?" if block_given?
15
+ local_registrations << ObjectRegistration.new(listener, **options)
16
+ self
17
+ end
18
+
19
+ # subscribe a block
20
+ #
21
+ # @example
22
+ # my_publisher.on(:order_created) { |args| ... }
23
+ #
24
+ # @return [self]
25
+ def on(*events, &block)
26
+ raise ArgumentError, 'must give at least one event' if events.empty?
27
+ raise ArgumentError, 'must pass a block' if !block
28
+ local_registrations << BlockRegistration.new(block, on: events)
29
+ self
30
+ end
31
+
32
+ # broadcasts an event
33
+ #
34
+ # @example
35
+ # def call
36
+ # # ...
37
+ # broadcast(:finished, self)
38
+ # end
39
+ #
40
+ # @return [self]
41
+ def broadcast(event, *args, **kwargs)
42
+ registrations.each do | registration |
43
+ registration.broadcast(clean_event(event), self, *args, **kwargs)
44
+ end
45
+ self
46
+ end
47
+
48
+ alias :publish :broadcast
49
+
50
+ private :broadcast, :publish
51
+
52
+ module ClassMethods
53
+ # subscribe a listener
54
+ #
55
+ # @example
56
+ # MyPublisher.subscribe(MyListener.new)
57
+ #
58
+ def subscribe(listener, **options)
59
+ GlobalListeners.subscribe(listener, **options.merge(:scope => self))
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def local_registrations
66
+ @local_registrations ||= Set.new
67
+ end
68
+
69
+ def global_registrations
70
+ GlobalListeners.registrations
71
+ end
72
+
73
+ def temporary_registrations
74
+ TemporaryListeners.registrations
75
+ end
76
+
77
+ def registrations
78
+ local_registrations + global_registrations + temporary_registrations
79
+ end
80
+
81
+ def clean_event(event)
82
+ event.to_s.gsub('-', '_')
83
+ end
84
+
85
+ def self.included(base)
86
+ base.extend(ClassMethods)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,11 @@
1
+ # @api private
2
+
3
+ module Wisper
4
+ class BlockRegistration < Registration
5
+ def broadcast(event, publisher, *args, **kwargs)
6
+ if should_broadcast?(event)
7
+ listener.call(*args, **kwargs)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,43 @@
1
+ # @api private
2
+
3
+ module Wisper
4
+ class ObjectRegistration < Registration
5
+ attr_reader :with, :prefix, :allowed_classes, :broadcaster
6
+
7
+ def initialize(listener, **options)
8
+ super(listener, **options)
9
+ @with = options[:with]
10
+ @prefix = ValueObjects::Prefix.new options[:prefix]
11
+ @allowed_classes = Array(options[:scope]).map(&:to_s).to_set
12
+ @broadcaster = map_broadcaster(options[:async] || options[:broadcaster])
13
+ end
14
+
15
+ def broadcast(event, publisher, *args, **kwargs)
16
+ method_to_call = map_event_to_method(event)
17
+ if should_broadcast?(event) && listener.respond_to?(method_to_call) && publisher_in_scope?(publisher)
18
+ broadcaster.broadcast(listener, publisher, method_to_call, *args, **kwargs)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def publisher_in_scope?(publisher)
25
+ allowed_classes.empty? || publisher.class.ancestors.any? { |ancestor| allowed_classes.include?(ancestor.to_s) }
26
+ end
27
+
28
+ def map_event_to_method(event)
29
+ prefix + (with || event).to_s
30
+ end
31
+
32
+ def map_broadcaster(value)
33
+ return value if value.respond_to?(:broadcast)
34
+ value = :async if value == true
35
+ value = :default if value == nil
36
+ configuration.broadcasters.fetch(value)
37
+ end
38
+
39
+ def configuration
40
+ Wisper.configuration
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,18 @@
1
+ # @api private
2
+
3
+ module Wisper
4
+ class Registration
5
+ attr_reader :on, :listener
6
+
7
+ def initialize(listener, **options)
8
+ @listener = listener
9
+ @on = ValueObjects::Events.new options[:on]
10
+ end
11
+
12
+ private
13
+
14
+ def should_broadcast?(event)
15
+ on.include? event
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,41 @@
1
+ # Handles temporary global subscriptions
2
+
3
+ # @api private
4
+
5
+ module Wisper
6
+ class TemporaryListeners
7
+ def self.subscribe(*listeners, **options, &block)
8
+ new.subscribe(*listeners, **options, &block)
9
+ end
10
+
11
+ def self.registrations
12
+ new.registrations
13
+ end
14
+
15
+ def subscribe(*listeners, **options, &_block)
16
+ new_registrations = build_registrations(*listeners, **options)
17
+
18
+ begin
19
+ registrations.merge new_registrations
20
+ yield
21
+ ensure
22
+ registrations.subtract new_registrations
23
+ end
24
+ self
25
+ end
26
+
27
+ def registrations
28
+ Thread.current[key] ||= Set.new
29
+ end
30
+
31
+ private
32
+
33
+ def build_registrations(*listeners, **options)
34
+ listeners.map { |listener| ObjectRegistration.new(listener, **options) }
35
+ end
36
+
37
+ def key
38
+ '__wisper_temporary_listeners'
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,61 @@
1
+ module Wisper
2
+ module ValueObjects #:nodoc:
3
+ # Describes allowed events
4
+ #
5
+ # Duck-types the argument to quack like array of strings
6
+ # when responding to the {#include?} method call.
7
+ class Events
8
+
9
+ # @!scope class
10
+ # @!method new(on)
11
+ # Initializes a 'list' of events
12
+ #
13
+ # @param [NilClass, String, Symbol, Array, Regexp] list
14
+ #
15
+ # @raise [ArgumentError]
16
+ # if an argument if of unsupported type
17
+ #
18
+ # @return [undefined]
19
+ def initialize(list)
20
+ @list = list
21
+ end
22
+
23
+ # Check if given event is 'included' to the 'list' of events
24
+ #
25
+ # @param [#to_s] event
26
+ #
27
+ # @return [Boolean]
28
+ def include?(event)
29
+ appropriate_method.call(event.to_s)
30
+ end
31
+
32
+ private
33
+
34
+ def methods
35
+ {
36
+ NilClass => ->(_event) { true },
37
+ String => ->(event) { list == event },
38
+ Symbol => ->(event) { list.to_s == event },
39
+ Enumerable => ->(event) { list.map(&:to_s).include? event },
40
+ Regexp => ->(event) { list.match(event) || false }
41
+ }
42
+ end
43
+
44
+ def list
45
+ @list
46
+ end
47
+
48
+ def appropriate_method
49
+ @appropriate_method ||= methods[recognized_type]
50
+ end
51
+
52
+ def recognized_type
53
+ methods.keys.detect(&list.method(:is_a?)) || type_not_recognized
54
+ end
55
+
56
+ def type_not_recognized
57
+ fail(ArgumentError, "#{list.class} not supported for `on` argument")
58
+ end
59
+ end # class Events
60
+ end # module ValueObjects
61
+ end # module Wisper
@@ -0,0 +1,29 @@
1
+ module Wisper
2
+ module ValueObjects #:nodoc:
3
+ # Prefix for notifications
4
+ #
5
+ # @example
6
+ # Wisper::ValueObjects::Prefix.new nil # => ""
7
+ # Wisper::ValueObjects::Prefix.new "when" # => "when_"
8
+ # Wisper::ValueObjects::Prefix.new true # => "on_"
9
+ class Prefix < String
10
+ class << self
11
+ attr_accessor :default
12
+ end
13
+
14
+ # @param [true, nil, #to_s] value
15
+ #
16
+ # @return [undefined]
17
+ def initialize(value = nil)
18
+ super "#{ (value == true) ? default : value }_"
19
+ replace "" if self == "_"
20
+ end
21
+
22
+ private
23
+
24
+ def default
25
+ self.class.default || 'on'
26
+ end
27
+ end # class Prefix
28
+ end # module ValueObjects
29
+ end # module Wisper
@@ -0,0 +1,3 @@
1
+ module Wisper
2
+ VERSION = "4.0.0"
3
+ end
data/lib/wisper.rb ADDED
@@ -0,0 +1,65 @@
1
+ require 'set'
2
+ require 'wisper/version'
3
+ require 'wisper/configuration'
4
+ require 'wisper/publisher'
5
+ require 'wisper/value_objects/prefix'
6
+ require 'wisper/value_objects/events'
7
+ require 'wisper/registration/registration'
8
+ require 'wisper/registration/object'
9
+ require 'wisper/registration/block'
10
+ require 'wisper/global_listeners'
11
+ require 'wisper/temporary_listeners'
12
+ require 'wisper/broadcasters/send_broadcaster'
13
+ require 'wisper/broadcasters/logger_broadcaster'
14
+
15
+ module Wisper
16
+ # Examples:
17
+ #
18
+ # Wisper.subscribe(AuditRecorder.new)
19
+ #
20
+ # Wisper.subscribe(AuditRecorder.new, StatsRecorder.new)
21
+ #
22
+ # Wisper.subscribe(AuditRecorder.new, on: 'order_created')
23
+ #
24
+ # Wisper.subscribe(AuditRecorder.new, scope: 'MyPublisher')
25
+ #
26
+ # Wisper.subscribe(AuditRecorder.new, StatsRecorder.new) do
27
+ # # ..
28
+ # end
29
+ #
30
+ def self.subscribe(*args, **kwargs, &block)
31
+ if block_given?
32
+ TemporaryListeners.subscribe(*args, **kwargs, &block)
33
+ else
34
+ GlobalListeners.subscribe(*args, **kwargs)
35
+ end
36
+ end
37
+
38
+ def self.unsubscribe(*listeners)
39
+ GlobalListeners.unsubscribe(*listeners)
40
+ end
41
+
42
+ def self.publisher
43
+ Publisher
44
+ end
45
+
46
+ def self.clear
47
+ GlobalListeners.clear
48
+ end
49
+
50
+ def self.configure
51
+ yield(configuration)
52
+ end
53
+
54
+ def self.configuration
55
+ @configuration ||= Configuration.new
56
+ end
57
+
58
+ def self.setup
59
+ configure do |config|
60
+ config.broadcaster(:default, Broadcasters::SendBroadcaster.new)
61
+ end
62
+ end
63
+ end
64
+
65
+ Wisper.setup
@@ -0,0 +1,82 @@
1
+ describe Wisper::GlobalListeners do
2
+ let(:global_listener) { double('listener') }
3
+ let(:local_listener) { double('listener') }
4
+ let(:publisher) { publisher_class.new }
5
+
6
+ describe '.subscribe' do
7
+ it 'adds given listener to every publisher' do
8
+ Wisper::GlobalListeners.subscribe(global_listener)
9
+ expect(global_listener).to receive(:it_happened)
10
+ publisher.send(:broadcast, :it_happened)
11
+ end
12
+
13
+ it 'works with options' do
14
+ Wisper::GlobalListeners.subscribe(global_listener, :on => :it_happened,
15
+ :with => :woot)
16
+ expect(global_listener).to receive(:woot).once
17
+ expect(global_listener).not_to receive(:it_happened_again)
18
+ publisher.send(:broadcast, :it_happened)
19
+ publisher.send(:broadcast, :it_happened_again)
20
+ end
21
+
22
+ it 'works along side local listeners' do
23
+ # global listener
24
+ Wisper::GlobalListeners.subscribe(global_listener)
25
+
26
+ # local listener
27
+ publisher.subscribe(local_listener)
28
+
29
+ expect(global_listener).to receive(:it_happened)
30
+ expect(local_listener).to receive(:it_happened)
31
+
32
+ publisher.send(:broadcast, :it_happened)
33
+ end
34
+
35
+ it 'can be scoped to classes' do
36
+ publisher_1 = publisher_class.new
37
+ publisher_2 = publisher_class.new
38
+ publisher_3 = publisher_class.new
39
+
40
+ Wisper::GlobalListeners.subscribe(global_listener, :scope => [publisher_1.class,
41
+ publisher_2.class])
42
+
43
+ expect(global_listener).to receive(:it_happened_1).once
44
+ expect(global_listener).to receive(:it_happened_2).once
45
+ expect(global_listener).not_to receive(:it_happened_3)
46
+
47
+ publisher_1.send(:broadcast, :it_happened_1)
48
+ publisher_2.send(:broadcast, :it_happened_2)
49
+ publisher_3.send(:broadcast, :it_happened_3)
50
+ end
51
+
52
+ it 'is threadsafe' do
53
+ num_threads = 100
54
+ (1..num_threads).to_a.map do
55
+ Thread.new do
56
+ Wisper::GlobalListeners.subscribe(Object.new)
57
+ sleep(rand) # a little chaos
58
+ end
59
+ end.each(&:join)
60
+
61
+ expect(Wisper::GlobalListeners.listeners.size).to eq num_threads
62
+ end
63
+ end
64
+
65
+ describe '.listeners' do
66
+ it 'returns collection of global listeners' do
67
+ Wisper::GlobalListeners.subscribe(global_listener)
68
+ expect(Wisper::GlobalListeners.listeners).to eq [global_listener]
69
+ end
70
+
71
+ it 'returns an immutable collection' do
72
+ expect(Wisper::GlobalListeners.listeners).to be_frozen
73
+ expect { Wisper::GlobalListeners.listeners << global_listener }.to raise_error(RuntimeError)
74
+ end
75
+ end
76
+
77
+ it '.clear clears all global listeners' do
78
+ Wisper::GlobalListeners.subscribe(global_listener)
79
+ Wisper::GlobalListeners.clear
80
+ expect(Wisper::GlobalListeners.listeners).to be_empty
81
+ end
82
+ end
@@ -0,0 +1,56 @@
1
+ # Example
2
+ class MyCommand
3
+ include Wisper::Publisher
4
+
5
+ def execute(be_successful)
6
+ if be_successful
7
+ broadcast('success', 'hello')
8
+ else
9
+ broadcast('failure', 'world')
10
+ end
11
+ end
12
+ end
13
+
14
+ describe Wisper do
15
+
16
+ it 'subscribes object to all published events' do
17
+ listener = double('listener')
18
+ expect(listener).to receive(:success).with('hello', **{})
19
+
20
+ command = MyCommand.new
21
+
22
+ command.subscribe(listener)
23
+
24
+ command.execute(true)
25
+ end
26
+
27
+ it 'maps events to different methods' do
28
+ listener_1 = double('listener')
29
+ listener_2 = double('listener')
30
+ expect(listener_1).to receive(:happy_days).with('hello', **{})
31
+ expect(listener_2).to receive(:sad_days).with('world', **{})
32
+
33
+ command = MyCommand.new
34
+
35
+ command.subscribe(listener_1, :on => :success, :with => :happy_days)
36
+ command.subscribe(listener_2, :on => :failure, :with => :sad_days)
37
+
38
+ command.execute(true)
39
+ command.execute(false)
40
+ end
41
+
42
+ it 'subscribes block can be chained' do
43
+ insider = double('Insider')
44
+
45
+ expect(insider).to receive(:render).with('success')
46
+ expect(insider).to receive(:render).with('failure')
47
+
48
+ command = MyCommand.new
49
+
50
+ command.on(:success) { |message| insider.render('success') }
51
+ .on(:failure) { |message| insider.render('failure') }
52
+
53
+ command.execute(true)
54
+ command.execute(false)
55
+ end
56
+ end
@@ -0,0 +1,21 @@
1
+ class MyPublisher
2
+ include Wisper::Publisher
3
+
4
+ def do_something
5
+ # ...
6
+ broadcast(:bar, self)
7
+ broadcast(:foo, self)
8
+ end
9
+ end
10
+
11
+ describe 'simple publishing' do
12
+ it 'subscribes listener to events' do
13
+ listener = double('listener')
14
+ expect(listener).to receive(:foo).with((instance_of MyPublisher), **{})
15
+ expect(listener).to receive(:bar).with((instance_of MyPublisher), **{})
16
+
17
+ my_publisher = MyPublisher.new
18
+ my_publisher.subscribe(listener)
19
+ my_publisher.do_something
20
+ end
21
+ end