servus 0.1.4 → 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.
- checksums.yaml +4 -4
- data/.claude/commands/check-docs.md +1 -0
- data/.claude/commands/consistency-check.md +1 -0
- data/.claude/commands/fine-tooth-comb.md +1 -0
- data/.claude/commands/red-green-refactor.md +5 -0
- data/.claude/settings.json +15 -0
- data/.rubocop.yml +18 -2
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +10 -0
- data/IDEAS.md +1 -1
- data/READme.md +153 -5
- data/builds/servus-0.1.4.gem +0 -0
- data/builds/servus-0.1.5.gem +0 -0
- data/docs/core/2_architecture.md +32 -4
- data/docs/current_focus.md +569 -0
- data/docs/features/5_event_bus.md +244 -0
- data/docs/integration/1_configuration.md +60 -7
- data/docs/integration/2_testing.md +123 -0
- data/lib/generators/servus/event_handler/event_handler_generator.rb +59 -0
- data/lib/generators/servus/event_handler/templates/handler.rb.erb +86 -0
- data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +48 -0
- data/lib/generators/servus/service/service_generator.rb +4 -0
- data/lib/generators/servus/service/templates/arguments.json.erb +19 -10
- data/lib/generators/servus/service/templates/result.json.erb +8 -2
- data/lib/generators/servus/service/templates/service.rb.erb +101 -4
- data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
- data/lib/servus/base.rb +21 -5
- data/lib/servus/config.rb +34 -14
- data/lib/servus/event_handler.rb +275 -0
- data/lib/servus/events/bus.rb +137 -0
- data/lib/servus/events/emitter.rb +162 -0
- data/lib/servus/events/errors.rb +10 -0
- data/lib/servus/railtie.rb +16 -0
- data/lib/servus/support/validator.rb +27 -0
- data/lib/servus/testing/matchers.rb +88 -0
- data/lib/servus/testing.rb +2 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +6 -0
- metadata +19 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# @title Features / 5. Event Bus
|
|
2
|
+
|
|
3
|
+
# Event Bus
|
|
4
|
+
|
|
5
|
+
Servus includes an event-driven architecture for decoupling service logic from side effects. Services emit events, and EventHandlers subscribe to them and invoke downstream services.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
The Event Bus provides:
|
|
10
|
+
- **Emitters**: Services declare events they emit on success/failure
|
|
11
|
+
- **EventHandlers**: Subscribe to events and invoke services in response
|
|
12
|
+
- **Event Bus**: Routes events to registered handlers via ActiveSupport::Notifications
|
|
13
|
+
- **Payload Validation**: Optional JSON Schema validation for event payloads
|
|
14
|
+
|
|
15
|
+
## Service Event Emission
|
|
16
|
+
|
|
17
|
+
Services can emit events when they succeed or fail using the `emits` DSL:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
class CreateUser::Service < Servus::Base
|
|
21
|
+
emits :user_created, on: :success
|
|
22
|
+
emits :user_creation_failed, on: :failure
|
|
23
|
+
|
|
24
|
+
def initialize(email:, name:)
|
|
25
|
+
@email = email
|
|
26
|
+
@name = name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
user = User.create!(email: @email, name: @name)
|
|
31
|
+
success(user: user)
|
|
32
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
33
|
+
failure(e.message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Custom Payloads
|
|
39
|
+
|
|
40
|
+
By default, success events receive `result.data` and failure events receive `result.error`. Customize with a block or method:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class CreateUser::Service < Servus::Base
|
|
44
|
+
# Block-based payload
|
|
45
|
+
emits :user_created, on: :success do |result|
|
|
46
|
+
{ user_id: result.data[:user].id, email: result.data[:user].email }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Method-based payload
|
|
50
|
+
emits :user_stats_updated, on: :success, with: :stats_payload
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def stats_payload(result)
|
|
55
|
+
{ user_count: User.count, latest_user_id: result.data[:user].id }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Trigger Types
|
|
61
|
+
|
|
62
|
+
- `:success` - Fires when service returns `success(...)`
|
|
63
|
+
- `:failure` - Fires when service returns `failure(...)`
|
|
64
|
+
- `:error!` - Fires when service calls `error!(...)` (before exception is raised)
|
|
65
|
+
|
|
66
|
+
## Event Handlers
|
|
67
|
+
|
|
68
|
+
EventHandlers live in `app/events/` and subscribe to events using a declarative DSL:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
# app/events/user_created_handler.rb
|
|
72
|
+
class UserCreatedHandler < Servus::EventHandler
|
|
73
|
+
handles :user_created
|
|
74
|
+
|
|
75
|
+
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
76
|
+
{ user_id: payload[:user_id], email: payload[:email] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
invoke TrackAnalytics::Service, async: true do |payload|
|
|
80
|
+
{ event: 'user_created', user_id: payload[:user_id] }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Generator
|
|
86
|
+
|
|
87
|
+
Generate handlers with the Rails generator:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
rails g servus:event_handler user_created
|
|
91
|
+
# Creates:
|
|
92
|
+
# app/events/user_created_handler.rb
|
|
93
|
+
# spec/events/user_created_handler_spec.rb
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Invocation Options
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class UserCreatedHandler < Servus::EventHandler
|
|
100
|
+
handles :user_created
|
|
101
|
+
|
|
102
|
+
# Synchronous invocation (default)
|
|
103
|
+
invoke NotifyAdmin::Service do |payload|
|
|
104
|
+
{ message: "New user: #{payload[:email]}" }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Async via ActiveJob
|
|
108
|
+
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
109
|
+
{ user_id: payload[:user_id] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Async with specific queue
|
|
113
|
+
invoke SendWelcomeEmail::Service, async: true, queue: :mailers do |payload|
|
|
114
|
+
{ user_id: payload[:user_id] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Conditional invocation
|
|
118
|
+
invoke GrantPremiumRewards::Service, if: ->(p) { p[:premium] } do |payload|
|
|
119
|
+
{ user_id: payload[:user_id] }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
invoke SkipForPremium::Service, unless: ->(p) { p[:premium] } do |payload|
|
|
123
|
+
{ user_id: payload[:user_id] }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Emitting Events Directly
|
|
129
|
+
|
|
130
|
+
EventHandlers provide an `emit` class method for emitting events from controllers, jobs, or other code without a service:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class UsersController < ApplicationController
|
|
134
|
+
def create
|
|
135
|
+
user = User.create!(user_params)
|
|
136
|
+
UserCreatedHandler.emit({ user_id: user.id, email: user.email })
|
|
137
|
+
redirect_to user
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
This is useful when the event source isn't a Servus service.
|
|
143
|
+
|
|
144
|
+
## Payload Schema Validation
|
|
145
|
+
|
|
146
|
+
Define JSON schemas to validate event payloads:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class UserCreatedHandler < Servus::EventHandler
|
|
150
|
+
handles :user_created
|
|
151
|
+
|
|
152
|
+
schema payload: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
required: ['user_id', 'email'],
|
|
155
|
+
properties: {
|
|
156
|
+
user_id: { type: 'integer' },
|
|
157
|
+
email: { type: 'string', format: 'email' }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
162
|
+
{ user_id: payload[:user_id], email: payload[:email] }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
When `emit` is called, the payload is validated against the schema before the event is dispatched.
|
|
168
|
+
|
|
169
|
+
## Handler Validation
|
|
170
|
+
|
|
171
|
+
Enable strict validation to catch handlers subscribing to non-existent events:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# config/initializers/servus.rb
|
|
175
|
+
Servus.configure do |config|
|
|
176
|
+
config.strict_event_validation = true # Default: true
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Then manually validate (typically in a rake task or CI)
|
|
180
|
+
Servus::EventHandler.validate_all_handlers!
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
This helps catch typos and orphaned handlers during development and CI.
|
|
184
|
+
|
|
185
|
+
## Best Practices
|
|
186
|
+
|
|
187
|
+
### Single Event Per Service
|
|
188
|
+
|
|
189
|
+
Services should emit one event per trigger representing their core concern:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# Good - one event, handler coordinates reactions
|
|
193
|
+
class CreateUser::Service < Servus::Base
|
|
194
|
+
emits :user_created, on: :success
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
class UserCreatedHandler < Servus::EventHandler
|
|
198
|
+
handles :user_created
|
|
199
|
+
|
|
200
|
+
invoke SendWelcomeEmail::Service, async: true
|
|
201
|
+
invoke TrackAnalytics::Service, async: true
|
|
202
|
+
invoke NotifySlack::Service, async: true
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Avoid - service doing too much coordination
|
|
206
|
+
class CreateUser::Service < Servus::Base
|
|
207
|
+
emits :send_welcome_email, on: :success
|
|
208
|
+
emits :track_user_analytics, on: :success
|
|
209
|
+
emits :notify_slack, on: :success
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Naming Conventions
|
|
214
|
+
|
|
215
|
+
- Events: Past tense describing what happened (`user_created`, `payment_processed`)
|
|
216
|
+
- Handlers: Event name + "Handler" suffix (`UserCreatedHandler`)
|
|
217
|
+
|
|
218
|
+
### Handler Location
|
|
219
|
+
|
|
220
|
+
Handlers live in `app/events/` and are auto-loaded by the Railtie:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
app/events/
|
|
224
|
+
├── user_created_handler.rb
|
|
225
|
+
├── payment_processed_handler.rb
|
|
226
|
+
└── order_completed_handler.rb
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Instrumentation
|
|
230
|
+
|
|
231
|
+
Events are instrumented via ActiveSupport::Notifications and appear in Rails logs:
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
servus.events.user_created (1.2ms) {:user_id=>123, :email=>"user@example.com"}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Subscribe to events programmatically:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, *args|
|
|
241
|
+
event_name = name.sub('servus.events.', '')
|
|
242
|
+
Rails.logger.info "Event emitted: #{event_name}"
|
|
243
|
+
end
|
|
244
|
+
```
|
|
@@ -2,22 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
# Configuration
|
|
4
4
|
|
|
5
|
-
Servus works without configuration.
|
|
5
|
+
Servus works without configuration. Optional settings exist for customizing directories and event validation.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Directory Configuration
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Change the location if needed:
|
|
9
|
+
Configure where Servus looks for schemas, services, and event handlers:
|
|
12
10
|
|
|
13
11
|
```ruby
|
|
14
12
|
# config/initializers/servus.rb
|
|
15
13
|
Servus.configure do |config|
|
|
16
|
-
|
|
14
|
+
# Default: 'app/schemas'
|
|
15
|
+
config.schemas_dir = 'app/schemas'
|
|
16
|
+
|
|
17
|
+
# Default: 'app/services'
|
|
18
|
+
config.services_dir = 'app/services'
|
|
19
|
+
|
|
20
|
+
# Default: 'app/events'
|
|
21
|
+
config.events_dir = 'app/events'
|
|
17
22
|
end
|
|
18
23
|
```
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
These affect legacy file-based schemas and handler auto-loading. Schemas defined via the `schema` DSL method do not use files.
|
|
21
26
|
|
|
22
27
|
## Schema Cache
|
|
23
28
|
|
|
@@ -49,3 +54,51 @@ config.active_job.default_queue_name = :default
|
|
|
49
54
|
```
|
|
50
55
|
|
|
51
56
|
Servus respects ActiveJob queue configuration - no Servus-specific setup needed.
|
|
57
|
+
|
|
58
|
+
## Event Bus Configuration
|
|
59
|
+
|
|
60
|
+
### Strict Event Validation
|
|
61
|
+
|
|
62
|
+
Enable strict validation to catch handlers subscribing to events that aren't emitted by any service:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# config/initializers/servus.rb
|
|
66
|
+
Servus.configure do |config|
|
|
67
|
+
# Default: true
|
|
68
|
+
config.strict_event_validation = true
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
When enabled, you can validate handlers at boot or in CI:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# In a rake task or initializer
|
|
76
|
+
Servus::EventHandler.validate_all_handlers!
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This raises `Servus::Events::OrphanedHandlerError` if any handler subscribes to a non-existent event.
|
|
80
|
+
|
|
81
|
+
### Handler Auto-Loading
|
|
82
|
+
|
|
83
|
+
In Rails, handlers in `app/events/` are automatically loaded. The Railtie:
|
|
84
|
+
- Clears the event bus on reload in development
|
|
85
|
+
- Eager-loads all `*_handler.rb` files from `config.events_dir`
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
app/events/
|
|
89
|
+
├── user_created_handler.rb
|
|
90
|
+
├── payment_processed_handler.rb
|
|
91
|
+
└── order_completed_handler.rb
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Event Instrumentation
|
|
95
|
+
|
|
96
|
+
Events are instrumented via ActiveSupport::Notifications with the prefix `servus.events.`:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# Subscribe to all Servus events
|
|
100
|
+
ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, *args|
|
|
101
|
+
event_name = name.sub('servus.events.', '')
|
|
102
|
+
Rails.logger.info "Event: #{event_name}"
|
|
103
|
+
end
|
|
104
|
+
```
|
|
@@ -162,3 +162,126 @@ it "validates required fields" do
|
|
|
162
162
|
}.to raise_error(Servus::Support::Errors::ValidationError, /required/)
|
|
163
163
|
end
|
|
164
164
|
```
|
|
165
|
+
|
|
166
|
+
## Testing Event Emission
|
|
167
|
+
|
|
168
|
+
Servus provides RSpec matchers for testing that services emit events.
|
|
169
|
+
|
|
170
|
+
### Setup
|
|
171
|
+
|
|
172
|
+
Include the matchers in your test suite:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
# spec/spec_helper.rb
|
|
176
|
+
require 'servus/testing'
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### emit_event Matcher
|
|
180
|
+
|
|
181
|
+
Assert that a block emits an event:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
RSpec.describe CreateUser::Service do
|
|
185
|
+
it 'emits user_created event on success' do
|
|
186
|
+
expect {
|
|
187
|
+
described_class.call(email: 'test@example.com', name: 'Test')
|
|
188
|
+
}.to emit_event(:user_created)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'emits event with expected payload' do
|
|
192
|
+
expect {
|
|
193
|
+
described_class.call(email: 'test@example.com', name: 'Test')
|
|
194
|
+
}.to emit_event(:user_created).with(hash_including(email: 'test@example.com'))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Using handler class instead of symbol
|
|
198
|
+
it 'emits to UserCreatedHandler' do
|
|
199
|
+
expect {
|
|
200
|
+
described_class.call(email: 'test@example.com', name: 'Test')
|
|
201
|
+
}.to emit_event(UserCreatedHandler)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### call_service Matcher
|
|
207
|
+
|
|
208
|
+
Assert that a handler invokes a service:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
RSpec.describe UserCreatedHandler do
|
|
212
|
+
let(:payload) { { user_id: 123, email: 'test@example.com' } }
|
|
213
|
+
|
|
214
|
+
it 'invokes SendWelcomeEmail::Service' do
|
|
215
|
+
expect {
|
|
216
|
+
described_class.handle(payload)
|
|
217
|
+
}.to call_service(SendWelcomeEmail::Service).with(user_id: 123)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it 'invokes service asynchronously' do
|
|
221
|
+
expect {
|
|
222
|
+
described_class.handle(payload)
|
|
223
|
+
}.to call_service(SendWelcomeEmail::Service).async
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Testing EventHandler Directly
|
|
229
|
+
|
|
230
|
+
Test handlers by calling their `handle` class method:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
RSpec.describe UserCreatedHandler do
|
|
234
|
+
let(:payload) { { user_id: 123, email: 'test@example.com' } }
|
|
235
|
+
|
|
236
|
+
before do
|
|
237
|
+
allow(SendWelcomeEmail::Service).to receive(:call_async)
|
|
238
|
+
allow(TrackAnalytics::Service).to receive(:call_async)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it 'invokes SendWelcomeEmail with mapped arguments' do
|
|
242
|
+
described_class.handle(payload)
|
|
243
|
+
|
|
244
|
+
expect(SendWelcomeEmail::Service)
|
|
245
|
+
.to have_received(:call_async)
|
|
246
|
+
.with(user_id: 123, email: 'test@example.com')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'invokes TrackAnalytics with event data' do
|
|
250
|
+
described_class.handle(payload)
|
|
251
|
+
|
|
252
|
+
expect(TrackAnalytics::Service)
|
|
253
|
+
.to have_received(:call_async)
|
|
254
|
+
.with(event: 'user_created', user_id: 123)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
context 'with conditional invocation' do
|
|
258
|
+
it 'skips premium rewards for non-premium users' do
|
|
259
|
+
allow(GrantPremiumRewards::Service).to receive(:call)
|
|
260
|
+
|
|
261
|
+
described_class.handle(payload.merge(premium: false))
|
|
262
|
+
|
|
263
|
+
expect(GrantPremiumRewards::Service).not_to have_received(:call)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Testing Event Emission from Handler
|
|
270
|
+
|
|
271
|
+
Test the `emit` class method:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
RSpec.describe UserCreatedHandler do
|
|
275
|
+
it 'emits the user_created event' do
|
|
276
|
+
expect {
|
|
277
|
+
described_class.emit({ user_id: 123, email: 'test@example.com' })
|
|
278
|
+
}.to emit_event(:user_created)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it 'validates payload against schema' do
|
|
282
|
+
expect {
|
|
283
|
+
described_class.emit({ invalid: 'payload' })
|
|
284
|
+
}.to raise_error(Servus::Support::Errors::ValidationError)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Generators
|
|
5
|
+
# Rails generator for creating Servus event handlers.
|
|
6
|
+
#
|
|
7
|
+
# Generates an event handler class and spec file.
|
|
8
|
+
#
|
|
9
|
+
# @example Generate an event handler
|
|
10
|
+
# rails g servus:event_handler user_created
|
|
11
|
+
#
|
|
12
|
+
# @example Generated files
|
|
13
|
+
# app/events/user_created_handler.rb
|
|
14
|
+
# spec/app/events/user_created_handler_spec.rb
|
|
15
|
+
#
|
|
16
|
+
# @see https://guides.rubyonrails.org/generators.html
|
|
17
|
+
class EventHandlerGenerator < Rails::Generators::NamedBase
|
|
18
|
+
source_root File.expand_path('templates', __dir__)
|
|
19
|
+
|
|
20
|
+
class_option :no_docs, type: :boolean,
|
|
21
|
+
default: false,
|
|
22
|
+
desc: 'Skip documentation comments in generated files'
|
|
23
|
+
|
|
24
|
+
# Creates the event handler and spec files.
|
|
25
|
+
#
|
|
26
|
+
# @return [void]
|
|
27
|
+
def create_handler_file
|
|
28
|
+
template 'handler.rb.erb', handler_path
|
|
29
|
+
template 'handler_spec.rb.erb', handler_spec_path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Returns the path for the handler file.
|
|
35
|
+
#
|
|
36
|
+
# @return [String] handler file path
|
|
37
|
+
# @api private
|
|
38
|
+
def handler_path
|
|
39
|
+
File.join(Servus.config.events_dir, "#{file_name}_handler.rb")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the path for the handler spec file.
|
|
43
|
+
#
|
|
44
|
+
# @return [String] spec file path
|
|
45
|
+
# @api private
|
|
46
|
+
def handler_spec_path
|
|
47
|
+
File.join('spec', Servus.config.events_dir, "#{file_name}_handler_spec.rb")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the handler class name.
|
|
51
|
+
#
|
|
52
|
+
# @return [String] handler class name
|
|
53
|
+
# @api private
|
|
54
|
+
def handler_class_name
|
|
55
|
+
"#{class_name}Handler"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<%- unless options[:no_docs] -%>
|
|
4
|
+
# Handles the :<%= file_name %> event by invoking configured services.
|
|
5
|
+
#
|
|
6
|
+
# EventHandlers subscribe to a single event and declaratively map it to one or
|
|
7
|
+
# more service invocations. This provides clean separation between event emission
|
|
8
|
+
# (what happened) and event handling (what to do about it).
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# class <%= handler_class_name %> < Servus::EventHandler
|
|
12
|
+
# handles :<%= file_name %>
|
|
13
|
+
#
|
|
14
|
+
# # Invoke a service when this event fires
|
|
15
|
+
# invoke SendEmail::Service, async: true do |payload|
|
|
16
|
+
# { user_id: payload[:user_id], email: payload[:email] }
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example Multiple service invocations
|
|
21
|
+
# invoke SendWelcomeEmail::Service, async: true, queue: :mailers do |payload|
|
|
22
|
+
# { user_id: payload[:user_id] }
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# invoke TrackAnalytics::Service, async: true do |payload|
|
|
26
|
+
# { event: '<%= file_name %>', user_id: payload[:user_id] }
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @example Conditional invocation
|
|
30
|
+
# invoke GrantRewards::Service, if: ->(payload) { payload[:premium] } do |payload|
|
|
31
|
+
# { user_id: payload[:user_id] }
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# @example With payload schema validation
|
|
35
|
+
# schema payload: {
|
|
36
|
+
# type: 'object',
|
|
37
|
+
# required: ['user_id'],
|
|
38
|
+
# properties: {
|
|
39
|
+
# user_id: { type: 'integer' },
|
|
40
|
+
# email: { type: 'string', format: 'email' }
|
|
41
|
+
# }
|
|
42
|
+
# }
|
|
43
|
+
#
|
|
44
|
+
# @example Emit this event from anywhere
|
|
45
|
+
# # From controllers, jobs, rake tasks, etc.
|
|
46
|
+
# <%= handler_class_name %>.emit({ user_id: 123, email: 'user@example.com' })
|
|
47
|
+
#
|
|
48
|
+
# Available options for `invoke`:
|
|
49
|
+
# - async: true - Invoke service asynchronously via ActiveJob
|
|
50
|
+
# - queue: :queue_name - Specify ActiveJob queue (requires async: true)
|
|
51
|
+
# - if: ->(payload) {} - Condition that must be true to invoke
|
|
52
|
+
# - unless: ->(payload) {} - Condition that must be false to invoke
|
|
53
|
+
#
|
|
54
|
+
# @see Servus::EventHandler
|
|
55
|
+
# @see Servus::Events::Bus
|
|
56
|
+
<%- end -%>
|
|
57
|
+
class <%= handler_class_name %> < Servus::EventHandler
|
|
58
|
+
handles :<%= file_name %>
|
|
59
|
+
|
|
60
|
+
<%- unless options[:no_docs] -%>
|
|
61
|
+
# TODO: Define payload schema (optional but recommended)
|
|
62
|
+
# schema payload: {
|
|
63
|
+
# type: 'object',
|
|
64
|
+
# required: ['required_field'],
|
|
65
|
+
# properties: {
|
|
66
|
+
# required_field: { type: 'string' }
|
|
67
|
+
# }
|
|
68
|
+
# }
|
|
69
|
+
|
|
70
|
+
# TODO: Add service invocations
|
|
71
|
+
# invoke YourService, async: true do |payload|
|
|
72
|
+
# {
|
|
73
|
+
# # Map event payload to service arguments
|
|
74
|
+
# argument_name: payload[:field_name]
|
|
75
|
+
# }
|
|
76
|
+
# end
|
|
77
|
+
<%- end -%>
|
|
78
|
+
schema payload: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
description: 'JSON schema for the <%= handler_class_name %> event payload',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# invoke ExampleService, async: true do |payload|
|
|
84
|
+
# { example_arg: payload[:example_field] }
|
|
85
|
+
# end
|
|
86
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe <%= handler_class_name %> do
|
|
6
|
+
<%- unless options[:no_docs] -%>
|
|
7
|
+
# Test that the handler invokes the correct services with properly mapped arguments.
|
|
8
|
+
#
|
|
9
|
+
# Example test pattern:
|
|
10
|
+
# it 'invokes YourService with correct arguments' do
|
|
11
|
+
# expect(YourService).to receive(:call_async).with(user_id: 123)
|
|
12
|
+
# described_class.handle({ user_id: 123, email: 'test@example.com' })
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# For testing event emission from controllers/jobs:
|
|
16
|
+
# include Servus::Testing::EventHelpers
|
|
17
|
+
#
|
|
18
|
+
# it 'emits <%= file_name %> event' do
|
|
19
|
+
# servus_expect_event(:<%= file_name %>)
|
|
20
|
+
# .with_payload(hash_including(user_id: 123))
|
|
21
|
+
# .when { YourController.new.create }
|
|
22
|
+
# end
|
|
23
|
+
|
|
24
|
+
<%- end -%>
|
|
25
|
+
let(:payload) do
|
|
26
|
+
{
|
|
27
|
+
# TODO: Add sample payload fields
|
|
28
|
+
# user_id: 123,
|
|
29
|
+
# email: 'test@example.com'
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
<%- unless options[:no_docs] -%>
|
|
34
|
+
# TODO: Add tests for service invocations
|
|
35
|
+
# it 'invokes YourService with mapped arguments' do
|
|
36
|
+
# expect(YourService).to receive(:call_async).with(user_id: payload[:user_id])
|
|
37
|
+
# described_class.handle(payload)
|
|
38
|
+
# end
|
|
39
|
+
|
|
40
|
+
# TODO: Test conditional logic if using :if or :unless
|
|
41
|
+
# it 'skips invocation when condition is false' do
|
|
42
|
+
# expect(YourService).not_to receive(:call_async)
|
|
43
|
+
# described_class.handle(payload.merge(premium: false))
|
|
44
|
+
# end
|
|
45
|
+
<%- else -%>
|
|
46
|
+
# Add your tests here
|
|
47
|
+
<%- end -%>
|
|
48
|
+
end
|
|
@@ -24,6 +24,10 @@ module Servus
|
|
|
24
24
|
|
|
25
25
|
argument :parameters, type: :array, default: [], banner: 'parameter'
|
|
26
26
|
|
|
27
|
+
class_option :no_docs, type: :boolean,
|
|
28
|
+
default: false,
|
|
29
|
+
desc: 'Skip documentation comments in generated files'
|
|
30
|
+
|
|
27
31
|
# Creates all service-related files.
|
|
28
32
|
#
|
|
29
33
|
# Generates the service class, spec file, and schema files from templates.
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "<%= service_class_name %> Arguments",
|
|
4
|
+
"description": "JSON Schema for validating <%= service_class_name %> input arguments",
|
|
2
5
|
"type": "object",
|
|
3
6
|
"properties": {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
<%- parameters.each_with_index do |param, index| -%>
|
|
8
|
+
"<%= param %>": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "TODO: Describe the <%= param %> parameter"
|
|
11
|
+
}<%= index < parameters.length - 1 ? ',' : '' %>
|
|
12
|
+
<%- end -%>
|
|
13
|
+
<%- if parameters.empty? -%>
|
|
9
14
|
},
|
|
15
|
+
<%- else -%>
|
|
16
|
+
},
|
|
17
|
+
<%- end -%>
|
|
10
18
|
"required": [
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
]
|
|
15
|
-
|
|
19
|
+
<%- parameters.each_with_index do |param, index| -%>
|
|
20
|
+
"<%= param %>"<%= index < parameters.length - 1 ? ',' : '' %>
|
|
21
|
+
<%- end -%>
|
|
22
|
+
],
|
|
23
|
+
"additionalProperties": false
|
|
24
|
+
}
|