servus 0.1.3 → 0.1.5

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 (139) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/check-docs.md +1 -0
  3. data/.claude/commands/consistency-check.md +1 -0
  4. data/.claude/commands/fine-tooth-comb.md +1 -0
  5. data/.claude/commands/red-green-refactor.md +5 -0
  6. data/.claude/settings.json +15 -0
  7. data/.rubocop.yml +18 -2
  8. data/.yardopts +6 -0
  9. data/CHANGELOG.md +47 -0
  10. data/CLAUDE.md +10 -0
  11. data/IDEAS.md +5 -0
  12. data/READme.md +300 -47
  13. data/Rakefile +33 -0
  14. data/builds/servus-0.1.3.gem +0 -0
  15. data/builds/servus-0.1.4.gem +0 -0
  16. data/builds/servus-0.1.5.gem +0 -0
  17. data/docs/core/1_overview.md +77 -0
  18. data/docs/core/2_architecture.md +120 -0
  19. data/docs/core/3_service_objects.md +121 -0
  20. data/docs/current_focus.md +569 -0
  21. data/docs/features/1_schema_validation.md +119 -0
  22. data/docs/features/2_error_handling.md +121 -0
  23. data/docs/features/3_async_execution.md +81 -0
  24. data/docs/features/4_logging.md +64 -0
  25. data/docs/features/5_event_bus.md +244 -0
  26. data/docs/guides/1_common_patterns.md +90 -0
  27. data/docs/guides/2_migration_guide.md +175 -0
  28. data/docs/integration/1_configuration.md +104 -0
  29. data/docs/integration/2_testing.md +287 -0
  30. data/docs/integration/3_rails_integration.md +99 -0
  31. data/docs/yard/Servus/Base.html +1645 -0
  32. data/docs/yard/Servus/Config.html +582 -0
  33. data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
  34. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
  35. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
  36. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
  37. data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
  38. data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
  39. data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
  40. data/docs/yard/Servus/Extensions/Async.html +141 -0
  41. data/docs/yard/Servus/Extensions.html +117 -0
  42. data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
  43. data/docs/yard/Servus/Generators.html +115 -0
  44. data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
  45. data/docs/yard/Servus/Helpers.html +115 -0
  46. data/docs/yard/Servus/Railtie.html +134 -0
  47. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
  48. data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
  49. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
  50. data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
  51. data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
  52. data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
  53. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
  54. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
  55. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
  56. data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
  57. data/docs/yard/Servus/Support/Errors.html +140 -0
  58. data/docs/yard/Servus/Support/Logger.html +856 -0
  59. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
  60. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
  61. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
  62. data/docs/yard/Servus/Support/Rescuer.html +267 -0
  63. data/docs/yard/Servus/Support/Response.html +574 -0
  64. data/docs/yard/Servus/Support/Validator.html +1150 -0
  65. data/docs/yard/Servus/Support.html +119 -0
  66. data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
  67. data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
  68. data/docs/yard/Servus/Testing.html +142 -0
  69. data/docs/yard/Servus.html +343 -0
  70. data/docs/yard/_index.html +535 -0
  71. data/docs/yard/class_list.html +54 -0
  72. data/docs/yard/css/common.css +1 -0
  73. data/docs/yard/css/full_list.css +58 -0
  74. data/docs/yard/css/style.css +503 -0
  75. data/docs/yard/file.1_common_patterns.html +154 -0
  76. data/docs/yard/file.1_configuration.html +115 -0
  77. data/docs/yard/file.1_overview.html +142 -0
  78. data/docs/yard/file.1_schema_validation.html +188 -0
  79. data/docs/yard/file.2_architecture.html +157 -0
  80. data/docs/yard/file.2_error_handling.html +190 -0
  81. data/docs/yard/file.2_migration_guide.html +242 -0
  82. data/docs/yard/file.2_testing.html +227 -0
  83. data/docs/yard/file.3_async_execution.html +145 -0
  84. data/docs/yard/file.3_rails_integration.html +160 -0
  85. data/docs/yard/file.3_service_objects.html +191 -0
  86. data/docs/yard/file.4_logging.html +135 -0
  87. data/docs/yard/file.ErrorHandling.html +190 -0
  88. data/docs/yard/file.READme.html +674 -0
  89. data/docs/yard/file.architecture.html +157 -0
  90. data/docs/yard/file.async_execution.html +145 -0
  91. data/docs/yard/file.common_patterns.html +154 -0
  92. data/docs/yard/file.configuration.html +115 -0
  93. data/docs/yard/file.error_handling.html +190 -0
  94. data/docs/yard/file.logging.html +135 -0
  95. data/docs/yard/file.migration_guide.html +242 -0
  96. data/docs/yard/file.overview.html +142 -0
  97. data/docs/yard/file.rails_integration.html +160 -0
  98. data/docs/yard/file.schema_validation.html +188 -0
  99. data/docs/yard/file.service_objects.html +191 -0
  100. data/docs/yard/file.testing.html +227 -0
  101. data/docs/yard/file_list.html +119 -0
  102. data/docs/yard/frames.html +22 -0
  103. data/docs/yard/index.html +674 -0
  104. data/docs/yard/js/app.js +344 -0
  105. data/docs/yard/js/full_list.js +242 -0
  106. data/docs/yard/js/jquery.js +4 -0
  107. data/docs/yard/method_list.html +542 -0
  108. data/docs/yard/top-level-namespace.html +110 -0
  109. data/lib/generators/servus/event_handler/event_handler_generator.rb +59 -0
  110. data/lib/generators/servus/event_handler/templates/handler.rb.erb +86 -0
  111. data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +48 -0
  112. data/lib/generators/servus/service/service_generator.rb +68 -1
  113. data/lib/generators/servus/service/templates/arguments.json.erb +19 -10
  114. data/lib/generators/servus/service/templates/result.json.erb +8 -2
  115. data/lib/generators/servus/service/templates/service.rb.erb +102 -5
  116. data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
  117. data/lib/servus/base.rb +275 -58
  118. data/lib/servus/config.rb +83 -17
  119. data/lib/servus/event_handler.rb +275 -0
  120. data/lib/servus/events/bus.rb +137 -0
  121. data/lib/servus/events/emitter.rb +162 -0
  122. data/lib/servus/events/errors.rb +10 -0
  123. data/lib/servus/extensions/async/call.rb +50 -18
  124. data/lib/servus/extensions/async/errors.rb +23 -3
  125. data/lib/servus/extensions/async/ext.rb +10 -2
  126. data/lib/servus/extensions/async/job.rb +30 -9
  127. data/lib/servus/helpers/controller_helpers.rb +73 -37
  128. data/lib/servus/railtie.rb +16 -0
  129. data/lib/servus/support/errors.rb +135 -45
  130. data/lib/servus/support/rescuer.rb +189 -36
  131. data/lib/servus/support/response.rb +49 -7
  132. data/lib/servus/support/validator.rb +147 -19
  133. data/lib/servus/testing/example_builders.rb +133 -0
  134. data/lib/servus/testing/example_extractor.rb +309 -0
  135. data/lib/servus/testing/matchers.rb +88 -0
  136. data/lib/servus/testing.rb +19 -0
  137. data/lib/servus/version.rb +1 -1
  138. data/lib/servus.rb +6 -0
  139. metadata +135 -19
@@ -0,0 +1,569 @@
1
+ # Servus Event Bus Specification
2
+
3
+ ## 1. Overview & Philosophy
4
+
5
+ This document specifies the design and API for a native event bus within the Servus gem. The core philosophy is **clean separation of concerns**, ensuring that services remain focused on business logic, while a dedicated, lightweight layer handles the routing of events to service invocations.
6
+
7
+ This approach is defined by three distinct layers:
8
+
9
+ 1. **The Emitter (****`Servus::Base`****)**: A service that performs a business function and declares the events it emits upon completion.
10
+
11
+ 1. **The Mapper (****`Servus::EventHandler`****)**: A new, lightweight class whose sole responsibility is to subscribe to a single event and declaratively map it to one or more service invocations.
12
+
13
+ 1. **The Consumer (****`Servus::Base`****)**: A service that is invoked by the event handler and performs a subsequent business function, without needing any awareness of the eventing system.
14
+
15
+ This design avoids overloading service classes with subscription logic and eliminates the need for auto-generated code, resulting in a system that is explicit, discoverable, and highly maintainable.
16
+
17
+ ---
18
+
19
+ ## 2. Core Components
20
+
21
+ ### 2.1. The Emitter: `Servus::Base`
22
+
23
+ Services that emit events will use a class-level `emits` method to declare them.
24
+
25
+ #### **API: ****`emits(event_name, on:, with: nil)`**
26
+
27
+ - **`event_name`**** (Symbol)**: The unique name of the event (e.g., `:referral_created`).
28
+
29
+ - **`on`**** (Symbol)**: The trigger for automatic emission. Must be `:success` or `:failure`.
30
+
31
+ - **`with`**** (Symbol, Optional)**: The name of an instance method on the service that will build the event payload. This method receives the service's `Servus::Support::Response` object as its only argument.
32
+
33
+ If the `with:` option is omitted, the payload will be `result.data` for a success event or `{ error: result.error }` for a failure event.
34
+
35
+ #### **Example: Service Declaration**
36
+
37
+ ```ruby
38
+ # app/services/referrals/create_referral/service.rb
39
+ class Referrals::CreateReferral::Service < Servus::Base
40
+ # On success, emit :referral_created, building the payload with the
41
+ # :referral_payload instance method.
42
+ emits :referral_created, on: :success, with: :referral_payload
43
+
44
+ # On failure, emit :referral_failed with a default error payload.
45
+ emits :referral_failed, on: :failure
46
+
47
+ # On error, emit :referral_error with a default error payload
48
+ emits :referral_error, on: :error
49
+
50
+ def initialize(referee_id:)
51
+ @referee_id = referee_id
52
+ end
53
+
54
+ def call
55
+ # ... business logic ...
56
+
57
+ # The :referral_created event is automatically emitted here upon success.
58
+ success({ referral: @referral, referee: @referee, referrer: @referrer })
59
+ end
60
+
61
+ private
62
+
63
+ # This method is called by the event system to build the payload.
64
+ def referral_payload(result)
65
+ {
66
+ referral_id: result.data[:referral].id,
67
+ referee_id: result.data[:referee].id,
68
+ referrer_id: result.data[:referrer].id,
69
+ created_at: Time.current
70
+ }
71
+ end
72
+ end
73
+ ```
74
+
75
+ ### 2.2. The Mapper: `Servus::EventHandler`
76
+
77
+ This is a new, lightweight class that lives in the `app/events` directory. Each handler subscribes to a single event and maps it to one or more service invocations.
78
+
79
+ #### **API: ****`handles(event_name)`**
80
+
81
+ - **`event_name`**** (Symbol)**: The unique name of the event this class handles.
82
+
83
+ #### **API: ****`invoke(service_class, options = {}, &block)`**
84
+
85
+ - **`service_class`**** (Class)**: The `Servus::Base` subclass to be invoked.
86
+
87
+ - **`options`**** (Hash, Optional)**: A hash of options for the invocation.
88
+ - `:async` (Boolean): If `true`, invokes the service using `.call_async`. Defaults to `false`.
89
+ - `:queue` (Symbol): The queue name to use for async jobs.
90
+ - `:if` (Proc): A lambda that receives the payload and must return `true` for the invocation to proceed.
91
+ - `:unless` (Proc): A lambda that receives the payload and must return `false` for the invocation to proceed.
92
+
93
+ - **`&block`**** (Block)**: A block that receives the event payload and **must** return a hash of keyword arguments for the target service's `initialize` method.
94
+
95
+ #### **Example: Event Handler**
96
+
97
+ ```ruby
98
+ # app/events/referral_created_handler.rb
99
+ class ReferralCreatedHandler < Servus::EventHandler
100
+ # Subscribe to the :referral_created event.
101
+ handles :referral_created
102
+
103
+ # Define an invocation for the Rewards::GrantReferralRewards::Service.
104
+ invoke Rewards::GrantReferralRewards::Service, async: true do |payload|
105
+ # Map the event payload to the service's required arguments.
106
+ { user_id: payload[:referrer_id] }
107
+ end
108
+
109
+ # Define another invocation for the Referrals::ActivityNotifier::Service.
110
+ invoke Referrals::ActivityNotifier::Service, async: true, queue: :notifications do |payload|
111
+ { referral_id: payload[:referral_id] }
112
+ end
113
+ end
114
+ ```
115
+
116
+ ### 2.3. Automatic Registration
117
+
118
+ All classes inheriting from `Servus::EventHandler` within the `app/events` directory will be automatically discovered and registered by the gem at boot time. No manual configuration is required.
119
+
120
+ ---
121
+
122
+ ## 3. Directory Structure
123
+
124
+ The introduction of `EventHandler` classes establishes a new conventional directory:
125
+
126
+ ```
127
+ app/
128
+ ├── events/ # New directory for event handlers
129
+ │ ├── referral_created_handler.rb
130
+ │ └── user_graduated_handler.rb
131
+ ├── services/
132
+ │ ├── referrals/
133
+ │ │ └── create_referral/
134
+ │ │ └── service.rb # Emitter
135
+ │ └── rewards/
136
+ │ └── grant_referral_rewards/
137
+ │ └── service.rb # Consumer
138
+ └── ...
139
+ ```
140
+
141
+ ---
142
+
143
+ ## 4. Generators
144
+
145
+ A Rails generator will be provided to facilitate the creation of `EventHandler` classes.
146
+
147
+ #### **Command**
148
+
149
+ ```bash
150
+ $ rails g servus:event_handler referral_created
151
+ ```
152
+
153
+ #### **Output**
154
+
155
+ This command will generate two files:
156
+
157
+ 1. `app/events/referral_created_handler.rb`
158
+
159
+ 1. `spec/events/referral_created_handler_spec.rb`
160
+
161
+ #### **Generated Handler Template**
162
+
163
+ ```ruby
164
+ # app/events/referral_created_handler.rb
165
+ class ReferralCreatedHandler < Servus::EventHandler
166
+ handles :referral_created
167
+
168
+ # TODO: Add service invocations using the `invoke` DSL.
169
+ #
170
+ # Example:
171
+ # invoke SomeService, async: true do |payload|
172
+ # { argument_name: payload[:some_key] }
173
+ # end
174
+ end
175
+ ```
176
+
177
+ ---
178
+
179
+ ## 5. Testing Strategy
180
+
181
+ The separation of concerns enables focused and decoupled testing.
182
+
183
+ ### 5.1. Testing Event Emission
184
+
185
+ When testing a service, you should only assert that the correct event was emitted with the expected payload. A test helper will be provided for this.
186
+
187
+ ```ruby
188
+ # spec/services/referrals/create_referral/service_spec.rb
189
+ RSpec.describe Referrals::CreateReferral::Service do
190
+ include Servus::Events::TestHelpers
191
+
192
+ it 'emits a :referral_created event on success' do
193
+ # Assert that the block will cause the specified event to be emitted.
194
+ expect_event(:referral_created)
195
+ .with_payload(hash_including(:referral_id, :referee_id, :referrer_id))
196
+ .when { described_class.call(referee_id: referee.id) }
197
+ end
198
+ end
199
+ ```
200
+
201
+ ### 5.2. Testing an Event Handler
202
+
203
+ When testing a handler, you should provide a sample payload and assert that the correct services are invoked with the correctly mapped arguments.
204
+
205
+ ```ruby
206
+ # spec/events/referral_created_handler_spec.rb
207
+ RSpec.describe ReferralCreatedHandler do
208
+ let(:payload) do
209
+ {
210
+ referral_id: 'referral-123',
211
+ referrer_id: 'user-456',
212
+ referee_id: 'user-789',
213
+ created_at: Time.current
214
+ }
215
+ end
216
+
217
+ it 'invokes the GrantReferralRewards service with the correct user ID' do
218
+ expect(Rewards::GrantReferralRewards::Service)
219
+ .to receive(:call_async)
220
+ .with(user_id: 'user-456')
221
+
222
+ # Trigger the handler with the test payload.
223
+ described_class.handle(payload)
224
+ end
225
+
226
+ it 'invokes the ActivityNotifier service with the correct referral ID' do
227
+ expect(Referrals::ActivityNotifier::Service)
228
+ .to receive(:call_async)
229
+ .with(referral_id: 'referral-123', queue: :notifications)
230
+
231
+ described_class.handle(payload)
232
+ end
233
+ end
234
+ ```
235
+
236
+ ---
237
+
238
+ ## 6. Implementation Plan
239
+
240
+ This section provides a detailed, phase-by-phase breakdown of tasks required to implement the event bus feature. Each phase builds upon the previous one, and tasks are organized by logical implementation order.
241
+
242
+ ### Phase 1: Core Event Infrastructure
243
+
244
+ **Goal**: Establish the foundational event emission capability in `Servus::Base`.
245
+
246
+ - [ ] **Create Event Bus/Registry** (`lib/servus/events/bus.rb`)
247
+ - Create `Servus::Events::Bus` singleton class
248
+ - Implement event registration: `Bus.register_handler(event_name, handler_class)`
249
+ - Implement event emission: `Bus.emit(event_name, payload)`
250
+ - Store handlers in a thread-safe Hash: `@handlers = Concurrent::Hash.new { |h, k| h[k] = [] }`
251
+ - Add method to dispatch event to all registered handlers
252
+ - **Files**: `lib/servus/events/bus.rb`, `spec/servus/events/bus_spec.rb`
253
+
254
+ - [ ] **Add `emits` DSL to Servus::Base** (`lib/servus/base.rb`)
255
+ - Create class-level `emits(event_name, on:, with: nil)` method
256
+ - Store event declarations in class instance variable: `@event_emissions ||= []`
257
+ - Validate `on:` parameter is one of: `:success`, `:failure`, `:error`
258
+ - Store event config as: `{ event_name:, trigger:, payload_builder: }`
259
+ - Add accessor method: `def self.event_emissions; @event_emissions || []; end`
260
+ - **Files**: `lib/servus/base.rb:30-50`
261
+
262
+ - [ ] **Implement Automatic Event Emission** (`lib/servus/base.rb`)
263
+ - In `#call` method, after executing user's `#call` (around line 120):
264
+ - After success: trigger events where `on: :success`
265
+ - After failure: trigger events where `on: :failure`
266
+ - In rescue blocks: trigger events where `on: :error`
267
+ - Create private method `#emit_events_for(trigger_type, result)`
268
+ - **Files**: `lib/servus/base.rb:120-140`
269
+
270
+ - [ ] **Implement Payload Builder Logic** (`lib/servus/base.rb`)
271
+ - Create private method `#build_event_payload(event_config, result)`
272
+ - If `with:` option present: call instance method with `result` as argument
273
+ - If `with:` absent and success: return `result.data`
274
+ - If `with:` absent and failure/error: return `{ error: result.error }`
275
+ - Handle case where custom payload builder returns nil (log warning, use default)
276
+ - **Files**: `lib/servus/base.rb:250-270`
277
+
278
+ - [ ] **Write Comprehensive Specs**
279
+ - Test `emits` DSL declaration and storage
280
+ - Test automatic emission on success/failure/error
281
+ - Test custom payload builders via `with:` option
282
+ - Test default payloads when `with:` omitted
283
+ - Test multiple event declarations on same service
284
+ - Test events inherited by subclasses
285
+ - **Files**: `spec/servus/base_spec.rb:450-600` (new section)
286
+
287
+ ### Phase 2: EventHandler Base Class
288
+
289
+ **Goal**: Create the `Servus::EventHandler` class with the `handles` and `invoke` DSL.
290
+
291
+ - [ ] **Create EventHandler Base Class** (`lib/servus/event_handler.rb`)
292
+ - Create `Servus::EventHandler` class
293
+ - Add class instance variable: `@event_name` for event subscription
294
+ - Add class instance variable: `@invocations = []` for service mappings
295
+ - Add reader: `def self.event_name; @event_name; end`
296
+ - Add reader: `def self.invocations; @invocations || []; end`
297
+ - **Files**: `lib/servus/event_handler.rb`, `spec/servus/event_handler_spec.rb`
298
+
299
+ - [ ] **Implement `handles` DSL Method** (`lib/servus/event_handler.rb`)
300
+ - Create class method: `def self.handles(event_name)`
301
+ - Store event name: `@event_name = event_name`
302
+ - Automatically register with Bus: `Servus::Events::Bus.register_handler(event_name, self)`
303
+ - Raise error if `handles` called multiple times in same class
304
+ - **Files**: `lib/servus/event_handler.rb:20-30`
305
+
306
+ - [ ] **Implement `invoke` DSL Method** (`lib/servus/event_handler.rb`)
307
+ - Create class method: `def self.invoke(service_class, options = {}, &block)`
308
+ - Validate `service_class` is a subclass of `Servus::Base`
309
+ - Validate `options` keys are valid: `:async`, `:queue`, `:if`, `:unless`
310
+ - Require block to be present (raise error if missing)
311
+ - Store invocation config: `@invocations << { service_class:, options:, mapper: block }`
312
+ - **Files**: `lib/servus/event_handler.rb:40-60`
313
+
314
+ - [ ] **Implement Event Handling Dispatcher** (`lib/servus/event_handler.rb`)
315
+ - Create class method: `def self.handle(payload)`
316
+ - Iterate over `@invocations`
317
+ - For each invocation:
318
+ - Check `:if` condition (skip if returns false)
319
+ - Check `:unless` condition (skip if returns true)
320
+ - Call mapper block with payload to get service kwargs
321
+ - Invoke service: `service_class.call(**kwargs)` or `.call_async(**kwargs.merge(queue: options[:queue]))`
322
+ - Return array of results from all invocations
323
+ - **Files**: `lib/servus/event_handler.rb:70-95`
324
+
325
+ - [ ] **Handle Async Options** (`lib/servus/event_handler.rb`)
326
+ - When `async: true`, use `service_class.call_async(**kwargs)`
327
+ - Pass `:queue` option to `call_async` if present
328
+ - Ensure async calls work with existing `Servus::Extensions::Async` module
329
+ - **Files**: `lib/servus/event_handler.rb:85-90`
330
+
331
+ - [ ] **Implement Conditional Logic** (`lib/servus/event_handler.rb`)
332
+ - Create private method: `def self.should_invoke?(payload, options)`
333
+ - Check `:if` proc: `return false if options[:if] && !options[:if].call(payload)`
334
+ - Check `:unless` proc: `return false if options[:unless] && options[:unless].call(payload)`
335
+ - Return true if all conditions pass
336
+ - **Files**: `lib/servus/event_handler.rb:100-110`
337
+
338
+ - [ ] **Write Comprehensive Specs**
339
+ - Test `handles` DSL declaration and registration
340
+ - Test `invoke` DSL with various options
341
+ - Test `.handle(payload)` dispatches to services correctly
342
+ - Test conditional execution (`:if`, `:unless`)
343
+ - Test sync vs async invocation
344
+ - Test queue routing for async jobs
345
+ - Test multiple invocations in single handler
346
+ - Test payload mapping via block
347
+ - **Files**: `spec/servus/event_handler_spec.rb`
348
+
349
+ ### Phase 3: Automatic Handler Discovery
350
+
351
+ **Goal**: Auto-discover and register all EventHandler classes in `app/events/` at Rails boot.
352
+
353
+ - [ ] **Create Railtie for Initialization** (`lib/servus/railtie.rb`)
354
+ - Update existing railtie or create if doesn't exist
355
+ - Add initializer: `initializer 'servus.discover_event_handlers', after: :load_config_initializers`
356
+ - In initializer, call `Servus::Events::Loader.discover_handlers`
357
+ - **Files**: `lib/servus/railtie.rb:20-30`
358
+
359
+ - [ ] **Create Handler Discovery Loader** (`lib/servus/events/loader.rb`)
360
+ - Create `Servus::Events::Loader` module
361
+ - Method: `def self.discover_handlers`
362
+ - Scan `app/events/**/*_handler.rb` using `Dir.glob`
363
+ - Require each file: `require_dependency(file_path)`
364
+ - Return count of discovered handlers for logging
365
+ - **Files**: `lib/servus/events/loader.rb`, `spec/servus/events/loader_spec.rb`
366
+
367
+ - [ ] **Add Handler Conflict Detection** (`lib/servus/events/bus.rb`)
368
+ - In `Bus.register_handler`, detect if event already has handler
369
+ - Raise `Servus::Events::DuplicateHandlerError` if duplicate detected
370
+ - Include both handler class names in error message
371
+ - Add config option to allow multiple handlers (default: false)
372
+ - **Files**: `lib/servus/events/bus.rb:25-35`
373
+
374
+ - [ ] **Create Custom Errors** (`lib/servus/events/errors.rb`)
375
+ - Create `Servus::Events::DuplicateHandlerError < StandardError`
376
+ - Create `Servus::Events::UnregisteredEventError < StandardError`
377
+ - **Files**: `lib/servus/events/errors.rb`
378
+
379
+ - [ ] **Add Development Mode Reloading** (`lib/servus/railtie.rb`)
380
+ - Clear handler registry on code reload: `to_prepare` hook
381
+ - Call `Servus::Events::Bus.clear` before re-discovering
382
+ - Ensure handlers re-register properly in development
383
+ - **Files**: `lib/servus/railtie.rb:35-40`
384
+
385
+ - [ ] **Write Comprehensive Specs**
386
+ - Test handler discovery in dummy Rails app
387
+ - Test duplicate handler detection raises error
388
+ - Test handler reloading in development mode
389
+ - Test nested handler files are discovered
390
+ - Test handlers are properly registered with Bus
391
+ - **Files**: `spec/servus/events/loader_spec.rb`, `spec/integration/handler_discovery_spec.rb`
392
+
393
+ ### Phase 4: Test Helpers
394
+
395
+ **Goal**: Provide intuitive test helpers for asserting event emissions and testing handlers.
396
+
397
+ - [ ] **Create Test Helpers Module** (`lib/servus/events/test_helpers.rb`)
398
+ - Create `Servus::Events::TestHelpers` module
399
+ - Add RSpec-specific helpers
400
+ - Include event capture/inspection utilities
401
+ - **Files**: `lib/servus/events/test_helpers.rb`, `spec/servus/events/test_helpers_spec.rb`
402
+
403
+ - [ ] **Implement `expect_event` Matcher** (`lib/servus/events/test_helpers.rb`)
404
+ - Create chainable matcher: `expect_event(event_name)`
405
+ - Implement `.with_payload(expected_payload)` chain
406
+ - Implement `.when { block }` chain that executes code
407
+ - Capture events emitted during block execution
408
+ - Assert event was emitted with matching payload
409
+ - Use RSpec's `hash_including` for partial payload matching
410
+ - **Files**: `lib/servus/events/test_helpers.rb:10-60`
411
+
412
+ - [ ] **Create Event Capture Mechanism** (`lib/servus/events/test_helpers.rb`)
413
+ - Create thread-local event store: `@captured_events = []`
414
+ - Hook into `Bus.emit` to capture events during tests
415
+ - Method: `def capture_events(&block)` that returns array of emitted events
416
+ - Auto-clear captured events between test runs
417
+ - **Files**: `lib/servus/events/test_helpers.rb:70-90`
418
+
419
+ - [ ] **Add Handler Testing Utilities** (`lib/servus/events/test_helpers.rb`)
420
+ - Helper method: `trigger_event(event_name, payload)` for directly testing handlers
421
+ - Method to assert handler invoked specific service: `expect_handler_to_invoke(service_class)`
422
+ - Method to build sample payloads: `sample_payload_for(event_name)`
423
+ - **Files**: `lib/servus/events/test_helpers.rb:100-130`
424
+
425
+ - [ ] **Create RSpec Configuration** (`lib/servus/events/test_helpers.rb`)
426
+ - Add RSpec config to auto-include TestHelpers in event specs
427
+ - Add config to auto-clear event registry between tests
428
+ - Add matcher aliases for readability
429
+ - **Files**: `lib/servus/events/test_helpers.rb:140-160`
430
+
431
+ - [ ] **Write Comprehensive Specs and Examples**
432
+ - Test `expect_event` matcher with various payload matchers
433
+ - Test `.when` block execution and event capture
434
+ - Test negative cases (event not emitted, wrong payload)
435
+ - Test handler testing utilities
436
+ - Create example specs showing usage patterns
437
+ - **Files**: `spec/servus/events/test_helpers_spec.rb`, `spec/examples/event_testing_spec.rb`
438
+
439
+ ### Phase 5: Generator
440
+
441
+ **Goal**: Provide Rails generator for quickly scaffolding new EventHandler classes and specs.
442
+
443
+ - [ ] **Create Generator Class** (`lib/generators/servus/event_handler/event_handler_generator.rb`)
444
+ - Inherit from `Rails::Generators::NamedBase`
445
+ - Set source root: `source_root File.expand_path('templates', __dir__)`
446
+ - Define generator description and usage
447
+ - **Files**: `lib/generators/servus/event_handler/event_handler_generator.rb`
448
+
449
+ - [ ] **Implement File Generation Logic** (`lib/generators/servus/event_handler/event_handler_generator.rb`)
450
+ - Method: `def create_handler_file`
451
+ - Generate file at: `app/events/#{file_name}_handler.rb`
452
+ - Use ERB template with proper class name and event name
453
+ - Method: `def create_spec_file`
454
+ - Generate file at: `spec/events/#{file_name}_handler_spec.rb`
455
+ - **Files**: `lib/generators/servus/event_handler/event_handler_generator.rb:15-30`
456
+
457
+ - [ ] **Create Handler Template** (`lib/generators/servus/event_handler/templates/handler.rb.tt`)
458
+ - ERB template with `<%= class_name %>Handler < Servus::EventHandler`
459
+ - Include `handles :<%= event_name %>`
460
+ - Include TODO comment with example invoke usage
461
+ - **Files**: `lib/generators/servus/event_handler/templates/handler.rb.tt`
462
+
463
+ - [ ] **Create Spec Template** (`lib/generators/servus/event_handler/templates/handler_spec.rb.tt`)
464
+ - ERB template for RSpec test file
465
+ - Include sample payload `let` block
466
+ - Include example test for service invocation
467
+ - Include pending test for additional invocations
468
+ - **Files**: `lib/generators/servus/event_handler/templates/handler_spec.rb.tt`
469
+
470
+ - [ ] **Add Naming Conventions** (`lib/generators/servus/event_handler/event_handler_generator.rb`)
471
+ - Convert snake_case event names to proper class names
472
+ - Example: `referral_created` → `ReferralCreatedHandler`
473
+ - Handle multi-word event names correctly
474
+ - Add validation for event name format (only alphanumeric and underscores)
475
+ - **Files**: `lib/generators/servus/event_handler/event_handler_generator.rb:40-55`
476
+
477
+ - [ ] **Write Generator Specs** (`spec/generators/servus/event_handler_generator_spec.rb`)
478
+ - Test generator creates handler file in correct location
479
+ - Test generator creates spec file in correct location
480
+ - Test generated files have correct content/structure
481
+ - Test naming conventions work correctly
482
+ - Test generator with various event name formats
483
+ - Use `Rails::Generators::TestCase` for generator testing
484
+ - **Files**: `spec/generators/servus/event_handler_generator_spec.rb`
485
+
486
+ ### Phase 6: Documentation & Polish
487
+
488
+ **Goal**: Document the event bus feature and prepare for release.
489
+
490
+ - [ ] **Move Spec to Feature Docs** (`docs/features/5_event_bus.md`)
491
+ - Copy content from `docs/current_focus.md` to `docs/features/5_event_bus.md`
492
+ - Remove "Implementation Plan" section (internal only)
493
+ - Polish language to be present tense ("The event bus provides...")
494
+ - Add introduction paragraph linking to related features (async execution)
495
+ - **Files**: `docs/features/5_event_bus.md`
496
+
497
+ - [ ] **Update Current Focus** (`docs/current_focus.md`)
498
+ - Clear or archive current content
499
+ - Add new focus area (could be generator updates from IDEAS.md)
500
+ - Or mark as "Event bus implementation complete, awaiting next focus"
501
+ - **Files**: `docs/current_focus.md`
502
+
503
+ - [ ] **Update README** (`READme.md`)
504
+ - Add "Event Bus" section under features list
505
+ - Add quick example showing emitter → handler → consumer flow
506
+ - Add link to full documentation: `docs/features/5_event_bus.md`
507
+ - Keep example concise (10-15 lines)
508
+ - **Files**: `READme.md:30-60`
509
+
510
+ - [ ] **Add YARD Documentation** (various files)
511
+ - Document `Servus::Base.emits` with @param and @example tags
512
+ - Document `Servus::EventHandler` class and DSL methods
513
+ - Document `Servus::Events::Bus` public methods
514
+ - Document test helpers module and matchers
515
+ - Generate updated YARD docs: `bundle exec yard doc`
516
+ - **Files**: `lib/servus/base.rb`, `lib/servus/event_handler.rb`, etc.
517
+
518
+ - [ ] **Update CHANGELOG** (`CHANGELOG.md`)
519
+ - Add new section: `## [0.2.0] - Unreleased`
520
+ - List new features:
521
+ - Event bus with `emits` DSL for services
522
+ - `Servus::EventHandler` for mapping events to service invocations
523
+ - Automatic handler discovery in `app/events/`
524
+ - Test helpers with `expect_event` matcher
525
+ - Generator: `rails g servus:event_handler`
526
+ - Note any breaking changes (hopefully none)
527
+ - **Files**: `CHANGELOG.md:1-20`
528
+
529
+ - [ ] **Create Migration Guide** (`docs/guides/3_adding_events.md`)
530
+ - Guide for adding events to existing services
531
+ - Walkthrough: identify business events → add `emits` → create handler → test
532
+ - Best practices: when to use events vs direct service calls
533
+ - Common patterns: notification events, audit events, workflow triggers
534
+ - Troubleshooting: handler not found, payload mapping issues
535
+ - **Files**: `docs/guides/3_adding_events.md`
536
+
537
+ - [ ] **Update Version Number** (`lib/servus/version.rb`)
538
+ - Bump version to `0.2.0` (minor version for new feature)
539
+ - Update version in `servus.gemspec` if needed
540
+ - **Files**: `lib/servus/version.rb:3`
541
+
542
+ ---
543
+
544
+ ### Implementation Notes
545
+
546
+ **Testing Strategy**:
547
+ - Write specs FIRST for each component (TDD approach)
548
+ - Use dummy Rails app in `spec/dummy` for integration tests
549
+ - Test thread safety for Bus registry (use concurrent gem)
550
+
551
+ **Performance Considerations**:
552
+ - Event emission should add < 1ms to service execution
553
+ - Handler lookup should be O(1) using hash-based registry
554
+ - Consider async-by-default for most event handlers to avoid blocking
555
+
556
+ **Backward Compatibility**:
557
+ - All event features are opt-in (no breaking changes)
558
+ - Services without `emits` declarations work exactly as before
559
+ - No changes to existing public APIs
560
+
561
+ **Dependencies**:
562
+ - May need `concurrent-ruby` gem for thread-safe Bus registry
563
+ - Async features already depend on ActiveJob (no new dependencies)
564
+
565
+ **Phasing Approach**:
566
+ - Phases 1-2 can be merged as single PR (core functionality)
567
+ - Phase 3 requires Rails integration testing (separate PR recommended)
568
+ - Phases 4-6 are polish/DX improvements (can be bundled or separate)
569
+
@@ -0,0 +1,119 @@
1
+ # @title Features / 1. Schema Validation
2
+
3
+ # Schema Validation
4
+
5
+ Servus provides optional JSON Schema validation for service arguments and results. Validation is opt-in - services work fine without schemas.
6
+
7
+ ## How It Works
8
+
9
+ Define schemas using the `schema` DSL method (recommended) or as constants. The framework validates arguments before execution and results after execution. Invalid data raises `ValidationError`.
10
+
11
+ ### Preferred: Schema DSL Method
12
+
13
+ ```ruby
14
+ class ProcessPayment::Service < Servus::Base
15
+ schema(
16
+ arguments: {
17
+ type: "object",
18
+ required: ["user_id", "amount"],
19
+ properties: {
20
+ user_id: { type: "integer", example: 123 },
21
+ amount: { type: "number", minimum: 0.01, example: 100.0 }
22
+ }
23
+ },
24
+ result: {
25
+ type: "object",
26
+ required: ["transaction_id", "new_balance"],
27
+ properties: {
28
+ transaction_id: { type: "string", example: "txn_abc123" },
29
+ new_balance: { type: "number", example: 950.0 }
30
+ }
31
+ }
32
+ )
33
+ end
34
+ ```
35
+
36
+ **Pro tip:** Add `example` or `examples` keywords to your schemas. These values can be automatically extracted in tests using `servus_arguments_example()` and `servus_result_example()` helpers. See the [Testing documentation](../integration/testing.md#schema-example-helpers) for details.
37
+
38
+ You can define just one schema if needed:
39
+
40
+ ```ruby
41
+ class SendEmail::Service < Servus::Base
42
+ schema arguments: {
43
+ type: "object",
44
+ required: ["email", "subject"],
45
+ properties: {
46
+ email: { type: "string", format: "email" },
47
+ subject: { type: "string" }
48
+ }
49
+ }
50
+ end
51
+ ```
52
+
53
+ ### Alternative: Inline Constants
54
+
55
+ Constants are still supported for backwards compatibility:
56
+
57
+ ```ruby
58
+ class ProcessPayment::Service < Servus::Base
59
+ ARGUMENTS_SCHEMA = {
60
+ type: "object",
61
+ required: ["user_id", "amount"],
62
+ properties: {
63
+ user_id: { type: "integer" },
64
+ amount: { type: "number", minimum: 0.01 }
65
+ }
66
+ }.freeze
67
+
68
+ RESULT_SCHEMA = {
69
+ type: "object",
70
+ required: ["transaction_id", "new_balance"],
71
+ properties: {
72
+ transaction_id: { type: "string" },
73
+ new_balance: { type: "number" }
74
+ }
75
+ }.freeze
76
+ end
77
+ ```
78
+
79
+ ## File-Based Schemas
80
+
81
+ For complex schemas, use JSON files instead of inline definitions. Create files at:
82
+ - `app/schemas/services/service_name/arguments.json`
83
+ - `app/schemas/services/service_name/result.json`
84
+
85
+ ### Schema Lookup Precedence
86
+
87
+ Servus checks for schemas in this order:
88
+ 1. **schema DSL method** (if defined)
89
+ 2. **Inline constants** (ARGUMENTS_SCHEMA, RESULT_SCHEMA)
90
+ 3. **JSON files** (in schema_root directory)
91
+
92
+ Schemas are cached after first load for performance.
93
+
94
+ ## Three Layers of Validation
95
+
96
+ **Schema Validation** (Servus): Type safety and structure at service boundaries
97
+
98
+ **Business Rules** (Service Logic): Domain-specific constraints during execution
99
+
100
+ **Model Validation** (ActiveRecord): Database constraints before persistence
101
+
102
+ Each layer has a different purpose - don't duplicate validation across layers.
103
+
104
+ ## Configuration
105
+
106
+ Change the schema file location if needed:
107
+
108
+ ```ruby
109
+ # config/initializers/servus.rb
110
+ Servus.configure do |config|
111
+ config.schema_root = Rails.root.join('config/schemas')
112
+ end
113
+ ```
114
+
115
+ Clear the schema cache during development when schemas change:
116
+
117
+ ```ruby
118
+ Servus::Support::Validator.clear_cache!
119
+ ```