wisper-event 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e86546d0aef13dd7de8e6fb64877044ee9be46f00d5c32a6c63b6d3bbb66495f
4
+ data.tar.gz: 7956a180074ca8bce31ec686780e22e1fee8050f231336af39e77740d747f576
5
+ SHA512:
6
+ metadata.gz: f299ed79f231c972f086e1bb19fe0f527a5f497a099b96fb9b1cd9b00435b6c40ea43622030491a8632af8962e2b04b32fb29f680b8634174438fddc46ebccc8
7
+ data.tar.gz: 7b9164c5b3ec3e06d55630822e0879fbc9522d10930e658011eb79975d84089e257a399db5deb2e30518edbffbe9c77cf601c63d689cc7cf01c88822bfc952ae
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## 0.1.0
2
+
3
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,310 @@
1
+ # wisper-event
2
+
3
+ A structured event extension for the Wisper gem.
4
+
5
+ ## Why wisper-event?
6
+
7
+ The [Wisper gem](https://github.com/krisleech/wisper) is a popular micro-library for implementing the publisher-subscriber pattern in Ruby. While powerful and flexible, Wisper's approach of broadcasting symbols or strings with unstructured arguments can lead to:
8
+
9
+ 1. **Too loose coupling** - While loose coupling is generally desirable, Wisper's string/symbol-based events make it difficult to track relationships between publishers and subscribers as applications grow
10
+ 2. **Unclear interfaces** - Without structured events, argument signatures can change unexpectedly, causing silent failures
11
+ 3. **Poor discoverability** - It's challenging to find all handlers for a specific event across a large codebase
12
+
13
+ **wisper-event** provides a more structured approach by allowing you to use proper Ruby objects as events while maintaining backward compatibility with Wisper's string/symbol-based events. This lets you:
14
+
15
+ - Gradually migrate your codebase to structured events
16
+ - Have clear, well-defined event interfaces
17
+ - Implement compile-time checking of event handlers
18
+ - Easily find event usage with standard code search tools
19
+
20
+ This gem was inspired by the original Wisper author's other gems:
21
+ - [wisper_next](https://gitlab.com/kris.leech/wisper_next)
22
+ - [ma](https://gitlab.com/kris.leech/ma)
23
+
24
+ It solves very particular problem and might be not a good fit for your application.
25
+
26
+ ## Installation
27
+
28
+ Add this line to your application's Gemfile:
29
+
30
+ ```ruby
31
+ gem 'wisper-event'
32
+ ```
33
+
34
+ And then execute:
35
+
36
+ ```
37
+ bundle install
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ### Enabling wisper-event
43
+
44
+ After installing the gem, you need to apply the monkey patches:
45
+
46
+ ```ruby
47
+ WisperEvent.apply!
48
+ ```
49
+
50
+ This is best done during your application's initialization.
51
+
52
+ ### Creating structured events
53
+
54
+ Define your events as plain Ruby classes:
55
+
56
+ ```ruby
57
+ module Event
58
+ class OrderCreated
59
+ attr_reader :order_id, :customer_id
60
+
61
+ def initialize(order_id:, customer_id:)
62
+ @order_id = order_id
63
+ @customer_id = customer_id
64
+ end
65
+ end
66
+
67
+ class OrderFailed
68
+ attr_reader :reason
69
+
70
+ def initialize(reason)
71
+ @reason = reason
72
+ end
73
+ end
74
+ end
75
+ ```
76
+
77
+ ### Publishing events
78
+
79
+ You can publish both traditional Wisper events and structured events from the same publisher:
80
+
81
+ ```ruby
82
+ class OrderService
83
+ include Wisper::Publisher
84
+
85
+ def create(params)
86
+ # Business logic...
87
+ if order.save
88
+ # Traditional event with arguments
89
+ broadcast('order_created', order_id: order.id, customer_id: order.customer_id)
90
+
91
+ # Structured event
92
+ broadcast(Event::OrderCreated.new(order_id: order.id, customer_id: order.customer_id))
93
+ else
94
+ # Traditional event
95
+ broadcast('order_failed', order.errors.full_messages.join(", "))
96
+
97
+ # Structured event
98
+ broadcast(Event::OrderFailed.new(order.errors.full_messages.join(", ")))
99
+ end
100
+ end
101
+ end
102
+ ```
103
+
104
+ ### Handling events - traditional approach
105
+
106
+ The traditional Wisper approach still works:
107
+
108
+ ```ruby
109
+ class OrderNotifier
110
+ def order_created(order_id:, customer_id:)
111
+ # Handle the event...
112
+ end
113
+
114
+ def order_failed(reason)
115
+ # Handle the failure...
116
+ end
117
+ end
118
+
119
+ service = OrderService.new
120
+ service.subscribe(OrderNotifier.new)
121
+ service.create(params)
122
+ ```
123
+
124
+ ### Handling events - structured approach
125
+
126
+ To handle structured events, include the `Wisper::Listener` module and define your handlers using the `on` class method:
127
+
128
+ ```ruby
129
+ lass StructuredOrderHandler
130
+ include Wisper::Listener
131
+
132
+ def initialize(logger)
133
+ @logger = logger
134
+ end
135
+
136
+ on(Event::OrderCreated) do |event|
137
+ @logger.info("Order #{event.order_id} was created for customer #{event.customer_id}")
138
+ end
139
+
140
+ on(Event::OrderFailed) do |event|
141
+ @logger.error("Order creation failed: #{event.reason}")
142
+ end
143
+ end
144
+
145
+ service = OrderService.new
146
+ service.subscribe(StructuredOrderHandler.new(logger))
147
+ service.create(params)
148
+ ```
149
+
150
+ ### Subscribing with Blocks
151
+
152
+ You can also subscribe to structured events using blocks:
153
+
154
+ ```ruby
155
+ service = OrderService.new
156
+ service.on(Event::OrderCreated) { |event| puts "Order created: #{event.order_id}" }
157
+ .on(Event::OrderFailed) { |event| puts "Order failed: #{event.reason}" }
158
+ service.create(params)
159
+ ```
160
+
161
+ ## Required event handling
162
+
163
+ When using `Wisper::Listener`, every structured event **must have** a corresponding handler. If an event is received that the listener doesn't handle, a `Wisper::Listener::UnhandledEventError` will be raised:
164
+
165
+ ```ruby
166
+ class IncompleteListener
167
+ include Wisper::Listener
168
+
169
+ on(Event::OrderCreated) do |event|
170
+ # This handles Event::OrderCreated
171
+ end
172
+
173
+ # Missing handler for Event::OrderFailed!
174
+ end
175
+
176
+ # This will raise UnhandledEventError when Event::OrderFailed is broadcast
177
+ ```
178
+
179
+ This helps ensure that your listeners are complete and don't silently ignore events.
180
+
181
+ ## Testing
182
+
183
+ The gem includes RSpec matchers build on top of [wisper-rspec](https://github.com/krisleech/wisper-rspec) for testing broadcasted events:
184
+
185
+ ```ruby
186
+ # Add this to your spec helper
187
+ require 'wisper/rspec/matchers'
188
+ require 'wisper/rspec/event_matchers'
189
+
190
+ RSpec.configure do |config|
191
+ config.include(Wisper::RSpec::BroadcastMatcher)
192
+ config.include(Wisper::RSpec::BroadcastEventMatcher)
193
+ end
194
+
195
+ # In your specs
196
+ it 'broadcasts the proper event' do
197
+ service = OrderService.new
198
+
199
+ expect { service.create(valid_params) }
200
+ .to broadcast_event(Event::OrderCreated).with(order_id: 123, customer_id: 456)
201
+
202
+ expect { service.create(invalid_params) }
203
+ .to broadcast_event(Event::OrderFailed).with(message: "Invalid params")
204
+ end
205
+ ```
206
+
207
+ ## Migrating to structured events
208
+
209
+ Here's a migration strategy:
210
+
211
+ 1. Start by creating structured events that match your existing string/symbol events
212
+ 1. Update your publishers to broadcast both formats
213
+ 1. Create structured listeners for new code
214
+ 1. Gradually convert existing listeners to the structured format
215
+ 1. Once all listeners are updated to use structured events, you can remove the old string/symbol style broadcasts
216
+
217
+ ### Example migration
218
+
219
+ Before:
220
+
221
+ ```ruby
222
+ # Publisher
223
+ class OrderService
224
+ include Wisper::Publisher
225
+
226
+ def create(params)
227
+ if order.save
228
+ broadcast('order_created', order_id: order.id)
229
+ else
230
+ broadcast('order_failed', reason: order.errors.full_messages)
231
+ end
232
+ end
233
+ end
234
+
235
+ # Listener
236
+ class OrderNotifier
237
+ def order_created(order_id:)
238
+ # Handle event
239
+ end
240
+
241
+ def order_failed(reason:)
242
+ # Handle failure
243
+ end
244
+ end
245
+ ```
246
+
247
+ After
248
+
249
+ ```ruby
250
+ # Events
251
+ module Event
252
+ class OrderCreated
253
+ attr_reader :order_id
254
+
255
+ def initialize(order_id:)
256
+ @order_id = order_id
257
+ end
258
+ end
259
+
260
+ class OrderFailed
261
+ attr_reader :reason
262
+
263
+ def initialize(reason:)
264
+ @reason = reason
265
+ end
266
+ end
267
+ end
268
+
269
+ # Publisher (during migration)
270
+ class OrderService
271
+ include Wisper::Publisher
272
+
273
+ def create(params)
274
+ if order.save
275
+ # You can remove this line once all listeners are updated
276
+ broadcast('order_created', order_id: order.id)
277
+ broadcast(Event::OrderCreated.new(order_id: order.id))
278
+ else
279
+ # You can remove this line once all listeners are updated
280
+ broadcast('order_failed', reason: order.errors.full_messages)
281
+ broadcast(Event::OrderFailed.new(reason: order.errors.full_messages))
282
+ end
283
+ end
284
+ end
285
+
286
+ # Modified structured listener
287
+ class OrderNotifier
288
+ include Wisper::Listener
289
+
290
+ on(Event::OrderCreated) do |event|
291
+ # Handle event
292
+ end
293
+
294
+ on(Event::OrderFailed) do |event|
295
+ # Handle failure
296
+ end
297
+
298
+ # If listener is being re-used across different publishers you will be
299
+ # forced to broadcast structured events across those publishers and handle
300
+ # all those events in this listener - which is _desired_ behavior
301
+ end
302
+ ```
303
+
304
+ ## Limitations and assumptions
305
+
306
+ - This gem focuses on improving event structure, not on Wisper's delivery mechanisms
307
+ - Global listeners are not supported with structured events
308
+ - Asynchronous event handling is not directly supported - if you need to trigger async jobs, do so explicitly in your listeners
309
+ - Every structured event must be handled by listeners that include `Wisper::Listener`
310
+ - `on` / `with` events mapping is not supported as it makes no sense with structured events
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wisper
4
+ module Listener
5
+ UnhandledEventError = Class.new(StandardError)
6
+
7
+ # NOTE: mostly copied from ActiveSupport#underscore
8
+ def self.generated_method_name(event_class)
9
+ class_name = event_class.gsub('::', '_')
10
+ name =
11
+ class_name
12
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
13
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
14
+ .downcase
15
+
16
+ "on_#{name}"
17
+ end
18
+
19
+ def self.included(base)
20
+ base.extend(ClassMethods)
21
+ end
22
+
23
+ def _wisper_listener?
24
+ true
25
+ end
26
+
27
+ def trigger(event)
28
+ method_name = Wisper::Listener.generated_method_name(event.class.name)
29
+ if respond_to?(method_name)
30
+ public_send(method_name, event)
31
+ else
32
+ raise(
33
+ UnhandledEventError,
34
+ "Event #{event.class} not handled in #{self.class}"
35
+ )
36
+ end
37
+ end
38
+
39
+ module ClassMethods
40
+ def on(event_class, &block)
41
+ method_name = Wisper::Listener.generated_method_name(event_class.name)
42
+
43
+ define_method(method_name) do |event|
44
+ instance_exec(event, &block)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wisper
4
+ module RSpec
5
+ class EventObjectRecorder
6
+ attr_reader :captured_events
7
+
8
+ def initialize
9
+ @captured_events = []
10
+ end
11
+
12
+ def respond_to?(_method_name, _include_private = false)
13
+ true
14
+ end
15
+
16
+ def respond_to_missing?(*)
17
+ true
18
+ end
19
+
20
+ def _wisper_listener?
21
+ true
22
+ end
23
+
24
+ def method_missing(_method_name, *args)
25
+ @captured_events << args[0]
26
+ end
27
+
28
+ def trigger(event)
29
+ @captured_events << event
30
+ end
31
+
32
+ def received_event?(expected_event, expected_attributes = nil)
33
+ @captured_events.any? do |event|
34
+ expected_class = expected_event.is_a?(Class) ? expected_event : expected_event.class
35
+
36
+ if event.is_a?(expected_class)
37
+ if expected_attributes.nil?
38
+ expected_event.is_a?(Class) || event == expected_event
39
+ else
40
+ expected_attributes.all? do |key, value|
41
+ event.respond_to?(key) && event.public_send(key) == value
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ module BroadcastEventMatcher
50
+ class EventMatcher
51
+ include ::RSpec::Matchers::Composable
52
+
53
+ def initialize(event)
54
+ @expected_event = event
55
+ @expected_attributes = nil
56
+ @is_class = event.is_a?(Class)
57
+ end
58
+
59
+ def with(attributes)
60
+ @expected_attributes = attributes
61
+ self
62
+ end
63
+
64
+ def supports_block_expectations?
65
+ true
66
+ end
67
+
68
+ def matches?(block)
69
+ @recorder = EventObjectRecorder.new
70
+
71
+ Wisper.subscribe(@recorder) do
72
+ block.call
73
+ end
74
+
75
+ @recorder.received_event?(@expected_event, @expected_attributes)
76
+ end
77
+
78
+ def description
79
+ desc = + "broadcast event of type #{event_class_name}"
80
+ desc << " with attributes #{@expected_attributes.inspect}" if @expected_attributes
81
+ desc
82
+ end
83
+
84
+ def failure_message
85
+ msg = + "expected publisher to broadcast event of type #{event_class_name}"
86
+ msg << " with attributes #{@expected_attributes.inspect}" if @expected_attributes
87
+ msg << captured_events_list
88
+ msg
89
+ end
90
+
91
+ def failure_message_when_negated
92
+ msg = + "expected publisher not to broadcast event of type #{event_class_name}"
93
+ msg << " with attributes #{@expected_attributes.inspect}" if @expected_attributes
94
+ msg
95
+ end
96
+
97
+ def diffable?
98
+ true
99
+ end
100
+
101
+ def expected
102
+ @expected_event
103
+ end
104
+
105
+ def actual
106
+ @recorder.captured_events
107
+ end
108
+
109
+ private
110
+
111
+ def captured_events_list
112
+ if @recorder.captured_events.any?
113
+ events = @recorder.captured_events.map do |event|
114
+ event.is_a?(Array) ? "#{event[0]}(#{event[1..].join(', ')})" : event.inspect
115
+ end
116
+ " (actual events broadcast: #{events.join(', ')})"
117
+ else
118
+ ' (no events broadcast)'
119
+ end
120
+ end
121
+
122
+ def event_class_name
123
+ @is_class ? @expected_event.name : @expected_event.class.name
124
+ end
125
+ end
126
+
127
+ def broadcast_event(event)
128
+ EventMatcher.new(event)
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ ::RSpec::Matchers.define_negated_matcher :not_broadcast_event, :broadcast_event
135
+
136
+ RSpec.configure do |config|
137
+ config.include Wisper::RSpec::BroadcastEventMatcher
138
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'wisper_event'
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WisperEvent
4
+ module Patches
5
+ module BlockRegistration
6
+ def broadcast(event, _publisher, *args, **kwargs)
7
+ return unless should_broadcast?(event)
8
+
9
+ if event.is_a?(String) || event.is_a?(Symbol)
10
+ super
11
+ else
12
+ # Structured event
13
+ listener.call(event)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ Wisper::BlockRegistration.prepend(WisperEvent::Patches::BlockRegistration)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WisperEvent
4
+ module Patches
5
+ module Events
6
+ def include?(event)
7
+ if event.is_a?(String) || event.is_a?(Symbol)
8
+ super
9
+ # Structured event
10
+ elsif list.is_a?(Class)
11
+ event.is_a?(list)
12
+ elsif list.is_a?(Enumerable) && list.any? { |item| item.is_a?(Class) }
13
+ list.any? { |item| item.is_a?(Class) && event.is_a?(item) }
14
+ else
15
+ super
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def methods
22
+ {
23
+ NilClass => ->(_event) { true },
24
+ String => ->(event) { list == event },
25
+ Symbol => ->(event) { list.to_s == event },
26
+ Enumerable => ->(event) { list.map(&:to_s).include? event },
27
+ Regexp => ->(event) { list.match(event) || false },
28
+ # Structured event
29
+ Class => ->(event) { event.is_a?(list) }
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ Wisper::ValueObjects::Events.prepend(WisperEvent::Patches::Events)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WisperEvent
4
+ module Patches
5
+ module ObjectRegistration
6
+ def broadcast(event, publisher, *args, **kwargs)
7
+ if event.is_a?(String) || event.is_a?(Symbol)
8
+ super
9
+ # Structured event
10
+ # Wisper::Listeners are required to handle structured events
11
+ elsif listener.respond_to?(:_wisper_listener?)
12
+ listener.trigger(event)
13
+ # as method names for events are auto generated
14
+ # we might as well discard structured events for non-structured listeners
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ Wisper::ObjectRegistration.prepend(WisperEvent::Patches::ObjectRegistration)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WisperEvent
4
+ module Patches
5
+ module Publisher
6
+ def broadcast(event, *args, **kwargs)
7
+ registrations.each do |registration|
8
+ if event.is_a?(String) || event.is_a?(Symbol)
9
+ registration.broadcast(clean_event(event), self, *args, **kwargs)
10
+ else
11
+ # Structured event - pass them directly
12
+ registration.broadcast(event, self, *args, **kwargs)
13
+ end
14
+ end
15
+ self
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ Wisper::Publisher.prepend(WisperEvent::Patches::Publisher)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'patches/object_registration'
4
+ require_relative 'patches/block_registration'
5
+ require_relative 'patches/events'
6
+ require_relative 'patches/publisher'
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WisperEvent
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wisper'
4
+ require_relative 'wisper_event/version'
5
+
6
+ module WisperEvent
7
+ class << self
8
+ def apply!
9
+ require_relative 'wisper/listener'
10
+ require_relative 'wisper_event/patches'
11
+ end
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wisper-event
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rafal Wojsznis
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-04-22 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: wisper
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 3.0.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 3.0.0
26
+ description: Structured events for Wisper
27
+ email:
28
+ - rafal.wojsznis@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - CHANGELOG.md
34
+ - README.md
35
+ - lib/wisper-event.rb
36
+ - lib/wisper/listener.rb
37
+ - lib/wisper/rspec/event_matchers.rb
38
+ - lib/wisper_event.rb
39
+ - lib/wisper_event/patches.rb
40
+ - lib/wisper_event/patches/block_registration.rb
41
+ - lib/wisper_event/patches/events.rb
42
+ - lib/wisper_event/patches/object_registration.rb
43
+ - lib/wisper_event/patches/publisher.rb
44
+ - lib/wisper_event/version.rb
45
+ homepage: https://github.com/rwojsznis/wisper-event
46
+ licenses:
47
+ - MIT
48
+ metadata: {}
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '2.7'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.6.1
64
+ specification_version: 4
65
+ summary: Backward-compatible support for structured events in Wisper
66
+ test_files: []