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.
- 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/.yardopts +6 -0
- data/CHANGELOG.md +47 -0
- data/CLAUDE.md +10 -0
- data/IDEAS.md +5 -0
- data/READme.md +300 -47
- data/Rakefile +33 -0
- data/builds/servus-0.1.3.gem +0 -0
- data/builds/servus-0.1.4.gem +0 -0
- data/builds/servus-0.1.5.gem +0 -0
- data/docs/core/1_overview.md +77 -0
- data/docs/core/2_architecture.md +120 -0
- data/docs/core/3_service_objects.md +121 -0
- data/docs/current_focus.md +569 -0
- data/docs/features/1_schema_validation.md +119 -0
- data/docs/features/2_error_handling.md +121 -0
- data/docs/features/3_async_execution.md +81 -0
- data/docs/features/4_logging.md +64 -0
- data/docs/features/5_event_bus.md +244 -0
- data/docs/guides/1_common_patterns.md +90 -0
- data/docs/guides/2_migration_guide.md +175 -0
- data/docs/integration/1_configuration.md +104 -0
- data/docs/integration/2_testing.md +287 -0
- data/docs/integration/3_rails_integration.md +99 -0
- data/docs/yard/Servus/Base.html +1645 -0
- data/docs/yard/Servus/Config.html +582 -0
- data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
- data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
- data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
- data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
- data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
- data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
- data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
- data/docs/yard/Servus/Extensions/Async.html +141 -0
- data/docs/yard/Servus/Extensions.html +117 -0
- data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
- data/docs/yard/Servus/Generators.html +115 -0
- data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
- data/docs/yard/Servus/Helpers.html +115 -0
- data/docs/yard/Servus/Railtie.html +134 -0
- data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
- data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
- data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
- data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
- data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
- data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
- data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
- data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
- data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
- data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
- data/docs/yard/Servus/Support/Errors.html +140 -0
- data/docs/yard/Servus/Support/Logger.html +856 -0
- data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
- data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
- data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
- data/docs/yard/Servus/Support/Rescuer.html +267 -0
- data/docs/yard/Servus/Support/Response.html +574 -0
- data/docs/yard/Servus/Support/Validator.html +1150 -0
- data/docs/yard/Servus/Support.html +119 -0
- data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
- data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
- data/docs/yard/Servus/Testing.html +142 -0
- data/docs/yard/Servus.html +343 -0
- data/docs/yard/_index.html +535 -0
- data/docs/yard/class_list.html +54 -0
- data/docs/yard/css/common.css +1 -0
- data/docs/yard/css/full_list.css +58 -0
- data/docs/yard/css/style.css +503 -0
- data/docs/yard/file.1_common_patterns.html +154 -0
- data/docs/yard/file.1_configuration.html +115 -0
- data/docs/yard/file.1_overview.html +142 -0
- data/docs/yard/file.1_schema_validation.html +188 -0
- data/docs/yard/file.2_architecture.html +157 -0
- data/docs/yard/file.2_error_handling.html +190 -0
- data/docs/yard/file.2_migration_guide.html +242 -0
- data/docs/yard/file.2_testing.html +227 -0
- data/docs/yard/file.3_async_execution.html +145 -0
- data/docs/yard/file.3_rails_integration.html +160 -0
- data/docs/yard/file.3_service_objects.html +191 -0
- data/docs/yard/file.4_logging.html +135 -0
- data/docs/yard/file.ErrorHandling.html +190 -0
- data/docs/yard/file.READme.html +674 -0
- data/docs/yard/file.architecture.html +157 -0
- data/docs/yard/file.async_execution.html +145 -0
- data/docs/yard/file.common_patterns.html +154 -0
- data/docs/yard/file.configuration.html +115 -0
- data/docs/yard/file.error_handling.html +190 -0
- data/docs/yard/file.logging.html +135 -0
- data/docs/yard/file.migration_guide.html +242 -0
- data/docs/yard/file.overview.html +142 -0
- data/docs/yard/file.rails_integration.html +160 -0
- data/docs/yard/file.schema_validation.html +188 -0
- data/docs/yard/file.service_objects.html +191 -0
- data/docs/yard/file.testing.html +227 -0
- data/docs/yard/file_list.html +119 -0
- data/docs/yard/frames.html +22 -0
- data/docs/yard/index.html +674 -0
- data/docs/yard/js/app.js +344 -0
- data/docs/yard/js/full_list.js +242 -0
- data/docs/yard/js/jquery.js +4 -0
- data/docs/yard/method_list.html +542 -0
- data/docs/yard/top-level-namespace.html +110 -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 +68 -1
- 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 +102 -5
- data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
- data/lib/servus/base.rb +275 -58
- data/lib/servus/config.rb +83 -17
- 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/extensions/async/call.rb +50 -18
- data/lib/servus/extensions/async/errors.rb +23 -3
- data/lib/servus/extensions/async/ext.rb +10 -2
- data/lib/servus/extensions/async/job.rb +30 -9
- data/lib/servus/helpers/controller_helpers.rb +73 -37
- data/lib/servus/railtie.rb +16 -0
- data/lib/servus/support/errors.rb +135 -45
- data/lib/servus/support/rescuer.rb +189 -36
- data/lib/servus/support/response.rb +49 -7
- data/lib/servus/support/validator.rb +147 -19
- data/lib/servus/testing/example_builders.rb +133 -0
- data/lib/servus/testing/example_extractor.rb +309 -0
- data/lib/servus/testing/matchers.rb +88 -0
- data/lib/servus/testing.rb +19 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +6 -0
- metadata +135 -19
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
# Base class for event handlers that map events to service invocations.
|
|
5
|
+
#
|
|
6
|
+
# EventHandler classes live in app/events/ and use a declarative DSL to
|
|
7
|
+
# subscribe to events and invoke services in response. Each handler
|
|
8
|
+
# subscribes to a single event via the `handles` method.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic event handler
|
|
11
|
+
# class UserCreatedHandler < Servus::EventHandler
|
|
12
|
+
# handles :user_created
|
|
13
|
+
#
|
|
14
|
+
# invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
15
|
+
# { user_id: payload[:user_id] }
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @see Servus::Events::Bus
|
|
20
|
+
# @see Servus::Base
|
|
21
|
+
class EventHandler
|
|
22
|
+
class << self
|
|
23
|
+
# Declares which event this handler subscribes to.
|
|
24
|
+
#
|
|
25
|
+
# This method registers the handler with the event bus and stores
|
|
26
|
+
# the event name for later reference. Each handler can only subscribe
|
|
27
|
+
# to one event.
|
|
28
|
+
#
|
|
29
|
+
# @param event_name [Symbol] the name of the event to handle
|
|
30
|
+
# @return [void]
|
|
31
|
+
# @raise [RuntimeError] if handles is called multiple times
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# class UserCreatedHandler < Servus::EventHandler
|
|
35
|
+
# handles :user_created
|
|
36
|
+
# end
|
|
37
|
+
def handles(event_name)
|
|
38
|
+
raise "Handler already subscribed to :#{@event_name}. Cannot subscribe to :#{event_name}" if @event_name
|
|
39
|
+
|
|
40
|
+
@event_name = event_name
|
|
41
|
+
Servus::Events::Bus.register_handler(event_name, self)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the event name this handler is subscribed to.
|
|
45
|
+
#
|
|
46
|
+
# @return [Symbol, nil] the event name or nil if not yet configured
|
|
47
|
+
attr_reader :event_name
|
|
48
|
+
|
|
49
|
+
# Declares a service invocation in response to the event.
|
|
50
|
+
#
|
|
51
|
+
# Multiple invocations can be declared for a single event. Each invocation
|
|
52
|
+
# requires a block that maps the event payload to the service's arguments.
|
|
53
|
+
#
|
|
54
|
+
# @param service_class [Class] the service class to invoke (must inherit from Servus::Base)
|
|
55
|
+
# @param options [Hash] invocation options
|
|
56
|
+
# @option options [Boolean] :async invoke the service asynchronously via call_async
|
|
57
|
+
# @option options [Symbol] :queue the queue name for async jobs
|
|
58
|
+
# @option options [Proc] :if condition that must return true for invocation
|
|
59
|
+
# @option options [Proc] :unless condition that must return false for invocation
|
|
60
|
+
# @yield [payload] block that maps event payload to service arguments
|
|
61
|
+
# @yieldparam payload [Hash] the event payload
|
|
62
|
+
# @yieldreturn [Hash] keyword arguments for the service's initialize method
|
|
63
|
+
# @return [void]
|
|
64
|
+
#
|
|
65
|
+
# @example Basic invocation
|
|
66
|
+
# invoke SendEmail::Service do |payload|
|
|
67
|
+
# { user_id: payload[:user_id], email: payload[:email] }
|
|
68
|
+
# end
|
|
69
|
+
#
|
|
70
|
+
# @example Async invocation with queue
|
|
71
|
+
# invoke SendEmail::Service, async: true, queue: :mailers do |payload|
|
|
72
|
+
# { user_id: payload[:user_id] }
|
|
73
|
+
# end
|
|
74
|
+
#
|
|
75
|
+
# @example Conditional invocation
|
|
76
|
+
# invoke GrantRewards::Service, if: ->(p) { p[:premium] } do |payload|
|
|
77
|
+
# { user_id: payload[:user_id] }
|
|
78
|
+
# end
|
|
79
|
+
def invoke(service_class, options = {}, &block)
|
|
80
|
+
raise ArgumentError, 'Block required for payload mapping' unless block
|
|
81
|
+
|
|
82
|
+
@invocations ||= []
|
|
83
|
+
@invocations << {
|
|
84
|
+
service_class: service_class,
|
|
85
|
+
options: options,
|
|
86
|
+
mapper: block
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns all service invocations declared for this handler.
|
|
91
|
+
#
|
|
92
|
+
# @return [Array<Hash>] array of invocation configurations
|
|
93
|
+
def invocations
|
|
94
|
+
@invocations || []
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Defines the JSON schema for validating event payloads.
|
|
98
|
+
#
|
|
99
|
+
# @param payload [Hash, nil] JSON schema for validating event payloads
|
|
100
|
+
# @return [void]
|
|
101
|
+
#
|
|
102
|
+
# @example
|
|
103
|
+
# class UserCreatedHandler < Servus::EventHandler
|
|
104
|
+
# handles :user_created
|
|
105
|
+
#
|
|
106
|
+
# schema payload: {
|
|
107
|
+
# type: 'object',
|
|
108
|
+
# required: ['user_id', 'email'],
|
|
109
|
+
# properties: {
|
|
110
|
+
# user_id: { type: 'integer' },
|
|
111
|
+
# email: { type: 'string', format: 'email' }
|
|
112
|
+
# }
|
|
113
|
+
# }
|
|
114
|
+
# end
|
|
115
|
+
def schema(payload: nil)
|
|
116
|
+
@payload_schema = payload.with_indifferent_access if payload
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Returns the payload schema.
|
|
120
|
+
#
|
|
121
|
+
# @return [Hash, nil] the payload schema or nil if not defined
|
|
122
|
+
# @api private
|
|
123
|
+
attr_reader :payload_schema
|
|
124
|
+
|
|
125
|
+
# Emits the event this handler is subscribed to.
|
|
126
|
+
#
|
|
127
|
+
# Provides a type-safe, discoverable way to emit events from anywhere in
|
|
128
|
+
# the application (controllers, jobs, rake tasks) without creating a service.
|
|
129
|
+
#
|
|
130
|
+
# @param payload [Hash] the event payload
|
|
131
|
+
# @return [void]
|
|
132
|
+
# @raise [RuntimeError] if no event configured via `handles`
|
|
133
|
+
#
|
|
134
|
+
# @example Emit from controller
|
|
135
|
+
# class UsersController
|
|
136
|
+
# def create
|
|
137
|
+
# user = User.create!(params)
|
|
138
|
+
# UserCreatedHandler.emit({ user_id: user.id, email: user.email })
|
|
139
|
+
# redirect_to user
|
|
140
|
+
# end
|
|
141
|
+
# end
|
|
142
|
+
#
|
|
143
|
+
# @example Emit from background job
|
|
144
|
+
# class ProcessDataJob
|
|
145
|
+
# def perform(data_id)
|
|
146
|
+
# result = process_data(data_id)
|
|
147
|
+
# DataProcessedHandler.emit({ data_id: data_id, status: result })
|
|
148
|
+
# end
|
|
149
|
+
# end
|
|
150
|
+
def emit(payload)
|
|
151
|
+
raise 'No event configured. Call handles :event_name first.' unless @event_name
|
|
152
|
+
|
|
153
|
+
Servus::Support::Validator.validate_event_payload!(self, payload)
|
|
154
|
+
|
|
155
|
+
Servus::Events::Bus.emit(@event_name, payload)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Handles an event by invoking all configured services.
|
|
159
|
+
#
|
|
160
|
+
# Iterates through all declared invocations, evaluates conditions,
|
|
161
|
+
# maps the payload to service arguments, and invokes each service.
|
|
162
|
+
#
|
|
163
|
+
# @param payload [Hash] the event payload
|
|
164
|
+
# @return [Array<Servus::Support::Response>] results from all invoked services
|
|
165
|
+
#
|
|
166
|
+
# @example
|
|
167
|
+
# UserCreatedHandler.handle({ user_id: 123, email: 'user@example.com' })
|
|
168
|
+
def handle(payload)
|
|
169
|
+
invocations.map do |invocation|
|
|
170
|
+
next unless should_invoke?(payload, invocation[:options])
|
|
171
|
+
|
|
172
|
+
invoke_service(invocation, payload)
|
|
173
|
+
end.compact
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Validates that all registered handlers subscribe to events that are actually emitted by services.
|
|
177
|
+
#
|
|
178
|
+
# Checks all handlers against all service emissions and raises an error if any
|
|
179
|
+
# handler subscribes to a non-existent event. Helps catch typos and orphaned handlers.
|
|
180
|
+
#
|
|
181
|
+
# Respects the `Servus.config.strict_event_validation` setting - skips validation if false.
|
|
182
|
+
#
|
|
183
|
+
# @return [void]
|
|
184
|
+
# @raise [Servus::Events::OrphanedHandlerError] if a handler subscribes to a non-existent event
|
|
185
|
+
#
|
|
186
|
+
# @example
|
|
187
|
+
# Servus::EventHandler.validate_all_handlers!
|
|
188
|
+
def validate_all_handlers!
|
|
189
|
+
return unless Servus.config.strict_event_validation
|
|
190
|
+
|
|
191
|
+
emitted_events = collect_emitted_events
|
|
192
|
+
orphaned = find_orphaned_handlers(emitted_events)
|
|
193
|
+
|
|
194
|
+
return if orphaned.empty?
|
|
195
|
+
|
|
196
|
+
raise Servus::Events::OrphanedHandlerError,
|
|
197
|
+
"Handler(s) subscribe to non-existent events:\n" \
|
|
198
|
+
"#{orphaned.map { |h| " - #{h[:handler]} subscribes to :#{h[:event]}" }.join("\n")}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
# Collects all event names that are emitted by services.
|
|
204
|
+
#
|
|
205
|
+
# @return [Set<Symbol>] set of all emitted event names
|
|
206
|
+
# @api private
|
|
207
|
+
def collect_emitted_events
|
|
208
|
+
events = Set.new
|
|
209
|
+
|
|
210
|
+
ObjectSpace.each_object(Class)
|
|
211
|
+
.select { |klass| klass < Servus::Base }
|
|
212
|
+
.each do |service_class|
|
|
213
|
+
service_class.event_emissions.each_value do |emissions|
|
|
214
|
+
emissions.each { |emission| events << emission[:event_name] }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
events
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Finds handlers that subscribe to events not emitted by any service.
|
|
222
|
+
#
|
|
223
|
+
# @param emitted_events [Set<Symbol>] set of all emitted event names
|
|
224
|
+
# @return [Array<Hash>] array of orphaned handler info
|
|
225
|
+
# @api private
|
|
226
|
+
def find_orphaned_handlers(emitted_events)
|
|
227
|
+
orphaned = []
|
|
228
|
+
|
|
229
|
+
ObjectSpace.each_object(Class)
|
|
230
|
+
.select { |klass| klass < Servus::EventHandler && klass != Servus::EventHandler }
|
|
231
|
+
.each do |handler_class|
|
|
232
|
+
next unless handler_class.event_name
|
|
233
|
+
next if emitted_events.include?(handler_class.event_name)
|
|
234
|
+
|
|
235
|
+
orphaned << { handler: handler_class.name, event: handler_class.event_name }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
orphaned
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Invokes a single service with the mapped payload.
|
|
242
|
+
#
|
|
243
|
+
# @param invocation [Hash] the invocation configuration
|
|
244
|
+
# @param payload [Hash] the event payload
|
|
245
|
+
# @return [Servus::Support::Response] the service result
|
|
246
|
+
# @api private
|
|
247
|
+
def invoke_service(invocation, payload)
|
|
248
|
+
service_kwargs = invocation[:mapper].call(payload)
|
|
249
|
+
|
|
250
|
+
async = invocation.dig(:options, :async) || false
|
|
251
|
+
queue = invocation.dig(:options, :queue) || nil
|
|
252
|
+
|
|
253
|
+
if async
|
|
254
|
+
service_kwargs = service_kwargs.merge(queue: queue) if queue
|
|
255
|
+
invocation[:service_class].call_async(**service_kwargs)
|
|
256
|
+
else
|
|
257
|
+
invocation[:service_class].call(**service_kwargs)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Checks if a service should be invoked based on conditions.
|
|
262
|
+
#
|
|
263
|
+
# @param payload [Hash] the event payload
|
|
264
|
+
# @param options [Hash] the invocation options
|
|
265
|
+
# @return [Boolean] true if the service should be invoked
|
|
266
|
+
# @api private
|
|
267
|
+
def should_invoke?(payload, options)
|
|
268
|
+
return false if options[:if] && !options[:if].call(payload)
|
|
269
|
+
return false if options[:unless]&.call(payload)
|
|
270
|
+
|
|
271
|
+
true
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Events
|
|
5
|
+
# Thread-safe event bus for registering and dispatching event handlers.
|
|
6
|
+
#
|
|
7
|
+
# The Bus acts as a central registry that maps event names to their
|
|
8
|
+
# corresponding handler classes. It uses ActiveSupport::Notifications
|
|
9
|
+
# internally to provide instrumentation and thread-safe event dispatch.
|
|
10
|
+
#
|
|
11
|
+
# Events are automatically instrumented and will appear in Rails logs
|
|
12
|
+
# with timing information, making it easy to monitor event performance.
|
|
13
|
+
#
|
|
14
|
+
# @example Registering a handler
|
|
15
|
+
# class UserCreatedHandler < Servus::EventHandler
|
|
16
|
+
# handles :user_created
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Servus::Events::Bus.register_handler(:user_created, UserCreatedHandler)
|
|
20
|
+
#
|
|
21
|
+
# @example Retrieving handlers for an event
|
|
22
|
+
# handlers = Servus::Events::Bus.handlers_for(:user_created)
|
|
23
|
+
# handlers.each { |handler| handler.handle(payload) }
|
|
24
|
+
#
|
|
25
|
+
# @example Instrumentation in logs
|
|
26
|
+
# Bus.emit(:user_created, user_id: 123)
|
|
27
|
+
# # Rails log: servus.events.user_created (1.2ms) {:user_id=>123}
|
|
28
|
+
#
|
|
29
|
+
# @see Servus::EventHandler
|
|
30
|
+
class Bus
|
|
31
|
+
class << self
|
|
32
|
+
# Registers a handler class for a specific event.
|
|
33
|
+
#
|
|
34
|
+
# Multiple handlers can be registered for the same event, and they
|
|
35
|
+
# will all be invoked when the event is emitted. The handler is
|
|
36
|
+
# automatically subscribed to ActiveSupport::Notifications.
|
|
37
|
+
#
|
|
38
|
+
# Handlers are typically registered automatically when EventHandler
|
|
39
|
+
# classes are loaded at boot time via the `handles` DSL method.
|
|
40
|
+
#
|
|
41
|
+
# @param event_name [Symbol] the name of the event
|
|
42
|
+
# @param handler_class [Class] the handler class to register
|
|
43
|
+
# @return [Array] the updated array of handlers for this event
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# Bus.register_handler(:user_created, UserCreatedHandler)
|
|
47
|
+
def register_handler(event_name, handler_class)
|
|
48
|
+
handlers[event_name] ||= []
|
|
49
|
+
handlers[event_name] << handler_class
|
|
50
|
+
|
|
51
|
+
# Subscribe to ActiveSupport::Notifications
|
|
52
|
+
subscription = ActiveSupport::Notifications.subscribe(notification_name(event_name)) do |*args|
|
|
53
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
54
|
+
handler_class.handle(event.payload)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Store subscription for cleanup
|
|
58
|
+
subscriptions[event_name] ||= []
|
|
59
|
+
subscriptions[event_name] << subscription
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Retrieves all registered handlers for a specific event.
|
|
63
|
+
#
|
|
64
|
+
# Returns a duplicate array to prevent external modification of the
|
|
65
|
+
# internal handler registry.
|
|
66
|
+
#
|
|
67
|
+
# @param event_name [Symbol] the name of the event
|
|
68
|
+
# @return [Array<Class>] array of handler classes registered for this event
|
|
69
|
+
#
|
|
70
|
+
# @example
|
|
71
|
+
# handlers = Bus.handlers_for(:user_created)
|
|
72
|
+
# handlers.each { |handler| handler.handle(payload) }
|
|
73
|
+
def handlers_for(event_name)
|
|
74
|
+
(handlers[event_name] || []).dup
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Emits an event to all registered handlers with instrumentation.
|
|
78
|
+
#
|
|
79
|
+
# Uses ActiveSupport::Notifications to instrument the event, providing
|
|
80
|
+
# automatic timing and logging. The event will appear in Rails logs
|
|
81
|
+
# with duration and payload information.
|
|
82
|
+
#
|
|
83
|
+
# @param event_name [Symbol] the name of the event to emit
|
|
84
|
+
# @param payload [Hash] the event payload to pass to handlers
|
|
85
|
+
# @return [void]
|
|
86
|
+
#
|
|
87
|
+
# @example
|
|
88
|
+
# Bus.emit(:user_created, { user_id: 123, email: 'user@example.com' })
|
|
89
|
+
# # Rails log: servus.events.user_created (1.2ms) {:user_id=>123, :email=>"user@example.com"}
|
|
90
|
+
def emit(event_name, payload)
|
|
91
|
+
ActiveSupport::Notifications.instrument(notification_name(event_name), payload)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Clears all registered handlers and unsubscribes from notifications.
|
|
95
|
+
#
|
|
96
|
+
# Useful for testing and development mode reloading.
|
|
97
|
+
#
|
|
98
|
+
# @return [void]
|
|
99
|
+
#
|
|
100
|
+
# @example
|
|
101
|
+
# Bus.clear
|
|
102
|
+
def clear
|
|
103
|
+
subscriptions.values.flatten.each do |subscription|
|
|
104
|
+
ActiveSupport::Notifications.unsubscribe(subscription)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@handlers = nil
|
|
108
|
+
@subscriptions = nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Hash storing event handlers.
|
|
114
|
+
#
|
|
115
|
+
# @return [Hash] hash mapping event names to handler arrays
|
|
116
|
+
def handlers
|
|
117
|
+
@handlers ||= {}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Hash storing ActiveSupport::Notifications subscriptions.
|
|
121
|
+
#
|
|
122
|
+
# @return [Hash] hash mapping event names to subscription objects
|
|
123
|
+
def subscriptions
|
|
124
|
+
@subscriptions ||= {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Converts an event name to a namespaced notification name.
|
|
128
|
+
#
|
|
129
|
+
# @param event_name [Symbol] the event name
|
|
130
|
+
# @return [String] the namespaced notification name
|
|
131
|
+
def notification_name(event_name)
|
|
132
|
+
"servus.events.#{event_name}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Events
|
|
5
|
+
# Provides event emission DSL for service objects.
|
|
6
|
+
#
|
|
7
|
+
# This module adds the `emits` class method to services, allowing them to
|
|
8
|
+
# declare events that will be automatically emitted on success, failure, or error.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# class CreateUser < Servus::Base
|
|
12
|
+
# emits :user_created, on: :success
|
|
13
|
+
# emits :user_failed, on: :failure
|
|
14
|
+
# end
|
|
15
|
+
module Emitter
|
|
16
|
+
extend ActiveSupport::Concern
|
|
17
|
+
|
|
18
|
+
# Emits events for a service result.
|
|
19
|
+
#
|
|
20
|
+
# Called automatically after service execution completes. Determines the
|
|
21
|
+
# trigger type based on the result and emits all configured events.
|
|
22
|
+
#
|
|
23
|
+
# @param instance [Servus::Base] the service instance
|
|
24
|
+
# @param result [Servus::Support::Response] the service result
|
|
25
|
+
# @return [void]
|
|
26
|
+
# @api private
|
|
27
|
+
def self.emit_result_events!(instance, result)
|
|
28
|
+
trigger = result.success? ? :success : :failure
|
|
29
|
+
instance.send(:emit_events_for, trigger, result)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
# Declares an event that this service will emit.
|
|
34
|
+
#
|
|
35
|
+
# Events are automatically emitted when the service completes with the specified
|
|
36
|
+
# trigger condition (:success, :failure, or :error). Use the `with` option to
|
|
37
|
+
# provide a custom payload builder, or pass a block.
|
|
38
|
+
#
|
|
39
|
+
# @param event_name [Symbol] the name of the event to emit
|
|
40
|
+
# @param on [Symbol] when to emit (:success, :failure, or :error)
|
|
41
|
+
# @param with [Symbol, nil] optional instance method name for building the payload
|
|
42
|
+
# @yield [result] optional block for building the payload
|
|
43
|
+
# @yieldparam result [Servus::Support::Response] the service result
|
|
44
|
+
# @yieldreturn [Hash] the event payload
|
|
45
|
+
# @return [void]
|
|
46
|
+
#
|
|
47
|
+
# @example Emit on success with default payload
|
|
48
|
+
# class CreateUser < Servus::Base
|
|
49
|
+
# emits :user_created, on: :success
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# @example Emit with custom payload builder method
|
|
53
|
+
# class CreateUser < Servus::Base
|
|
54
|
+
# emits :user_created, on: :success, with: :user_payload
|
|
55
|
+
#
|
|
56
|
+
# private
|
|
57
|
+
#
|
|
58
|
+
# def user_payload(result)
|
|
59
|
+
# { user_id: result.data[:user].id }
|
|
60
|
+
# end
|
|
61
|
+
# end
|
|
62
|
+
#
|
|
63
|
+
# @example Emit with custom payload builder block
|
|
64
|
+
# class CreateUser < Servus::Base
|
|
65
|
+
# emits :user_created, on: :success do |result|
|
|
66
|
+
# { user_id: result.data[:user].id }
|
|
67
|
+
# end
|
|
68
|
+
# end
|
|
69
|
+
#
|
|
70
|
+
# @note Best Practice: Services should typically emit ONE event per trigger
|
|
71
|
+
# that represents their core concern. Multiple downstream reactions should
|
|
72
|
+
# be coordinated by EventHandler classes, not by emitting multiple events
|
|
73
|
+
# from the service. This maintains separation of concerns.
|
|
74
|
+
#
|
|
75
|
+
# @example Recommended pattern (one event, multiple handlers)
|
|
76
|
+
# # Service emits one event
|
|
77
|
+
# class CreateUser < Servus::Base
|
|
78
|
+
# emits :user_created, on: :success
|
|
79
|
+
# end
|
|
80
|
+
#
|
|
81
|
+
# # Handler coordinates multiple reactions
|
|
82
|
+
# class UserCreatedHandler < Servus::EventHandler
|
|
83
|
+
# handles :user_created
|
|
84
|
+
# invoke SendWelcomeEmail::Service, async: true
|
|
85
|
+
# invoke TrackAnalytics::Service, async: true
|
|
86
|
+
# end
|
|
87
|
+
#
|
|
88
|
+
# @see Servus::Events::Bus
|
|
89
|
+
# @see Servus::EventHandler
|
|
90
|
+
def emits(event_name, on:, with: nil, &block)
|
|
91
|
+
valid_triggers = %i[success failure error!]
|
|
92
|
+
|
|
93
|
+
unless valid_triggers.include?(on)
|
|
94
|
+
raise ArgumentError, "Invalid trigger: #{on}. Must be one of: #{valid_triggers.join(', ')}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@event_emissions ||= { success: [], failure: [], error!: [] }
|
|
98
|
+
@event_emissions[on] << {
|
|
99
|
+
event_name: event_name,
|
|
100
|
+
payload_builder: block || with
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns all event emissions declared for this service.
|
|
105
|
+
#
|
|
106
|
+
# @return [Hash] hash of event emissions grouped by trigger
|
|
107
|
+
# { success: [...], failure: [...], error!: [...] }
|
|
108
|
+
def event_emissions
|
|
109
|
+
@event_emissions || { success: [], failure: [], error!: [] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns event emissions for a specific trigger.
|
|
113
|
+
#
|
|
114
|
+
# @param trigger [Symbol] the trigger type (:success, :failure, :error!)
|
|
115
|
+
# @return [Array<Hash>] array of event configurations for this trigger
|
|
116
|
+
def emissions_for(trigger)
|
|
117
|
+
event_emissions[trigger] || []
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Instance methods for emitting events during service execution
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Emits events for a specific trigger with the given result.
|
|
125
|
+
#
|
|
126
|
+
# @param trigger [Symbol] the trigger type (:success, :failure, :error!)
|
|
127
|
+
# @param result [Servus::Support::Response] the service result
|
|
128
|
+
# @return [void]
|
|
129
|
+
# @api private
|
|
130
|
+
def emit_events_for(trigger, result)
|
|
131
|
+
self.class.emissions_for(trigger).each do |emission|
|
|
132
|
+
payload = build_event_payload(emission, result)
|
|
133
|
+
Servus::Events::Bus.emit(emission[:event_name], payload)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Builds the event payload using the configured payload builder or defaults.
|
|
138
|
+
#
|
|
139
|
+
# @param emission [Hash] the emission configuration
|
|
140
|
+
# @param result [Servus::Support::Response] the service result
|
|
141
|
+
# @return [Hash] the event payload
|
|
142
|
+
# @api private
|
|
143
|
+
def build_event_payload(emission, result)
|
|
144
|
+
builder = emission[:payload_builder]
|
|
145
|
+
|
|
146
|
+
if builder.is_a?(Proc)
|
|
147
|
+
# Block-based payload builder
|
|
148
|
+
builder.call(result)
|
|
149
|
+
elsif builder.is_a?(Symbol)
|
|
150
|
+
# Method-based payload builder
|
|
151
|
+
send(builder, result)
|
|
152
|
+
elsif result.success?
|
|
153
|
+
# Default for success: return data
|
|
154
|
+
result.data
|
|
155
|
+
else
|
|
156
|
+
# Default for failure/error: return error
|
|
157
|
+
result.error
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Events
|
|
5
|
+
# Raised when an event handler subscribes to an event that no service emits.
|
|
6
|
+
#
|
|
7
|
+
# This helps catch typos in event names and orphaned handlers.
|
|
8
|
+
class OrphanedHandlerError < StandardError; end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -3,28 +3,60 @@
|
|
|
3
3
|
module Servus
|
|
4
4
|
module Extensions
|
|
5
5
|
module Async
|
|
6
|
-
#
|
|
6
|
+
# Provides asynchronous service execution via ActiveJob.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# - wait_until: <Time> (e.g., 2.hours.from_now)
|
|
11
|
-
# - queue: <Symbol/String> (e.g., :critical, 'low_priority')
|
|
12
|
-
# - priority: <Integer> (depends on adapter support)
|
|
13
|
-
# - retry: <Boolean> (custom control for job retry)
|
|
14
|
-
# - job_options: <Hash> (extra options, merged in)
|
|
15
|
-
#
|
|
16
|
-
# Example:
|
|
17
|
-
# call_async(
|
|
18
|
-
# wait: 10.minutes,
|
|
19
|
-
# queue: :low_priority,
|
|
20
|
-
# priority: 20,
|
|
21
|
-
# job_options: { tags: ['user_graduation'] },
|
|
22
|
-
# user_id: current_user.id
|
|
23
|
-
# )
|
|
8
|
+
# This module extends {Servus::Base} with the {#call_async} method, enabling
|
|
9
|
+
# services to be executed in background jobs. Requires ActiveJob to be loaded.
|
|
24
10
|
#
|
|
11
|
+
# @see Call#call_async
|
|
25
12
|
module Call
|
|
26
|
-
#
|
|
13
|
+
# Enqueues the service for asynchronous execution via ActiveJob.
|
|
14
|
+
#
|
|
15
|
+
# This method schedules the service to run in a background job, supporting
|
|
16
|
+
# all standard ActiveJob options for scheduling, queue routing, and priority.
|
|
17
|
+
#
|
|
18
|
+
# Service arguments are passed as keyword arguments alongside job configuration.
|
|
19
|
+
# Job-specific options are extracted and the remaining arguments are passed
|
|
20
|
+
# to the service's initialize method.
|
|
21
|
+
#
|
|
22
|
+
# @param args [Hash] combined service arguments and job configuration options
|
|
23
|
+
# @option args [ActiveSupport::Duration] :wait delay before execution (e.g., 5.minutes)
|
|
24
|
+
# @option args [Time] :wait_until specific time to execute (e.g., 2.hours.from_now)
|
|
25
|
+
# @option args [Symbol, String] :queue queue name (e.g., :low_priority)
|
|
26
|
+
# @option args [Integer] :priority job priority (adapter-dependent)
|
|
27
|
+
# @option args [Hash] :job_options additional ActiveJob options
|
|
28
|
+
#
|
|
27
29
|
# @return [void]
|
|
30
|
+
# @raise [Servus::Extensions::Async::Errors::JobEnqueueError] if job enqueueing fails
|
|
31
|
+
#
|
|
32
|
+
# @example Basic async execution
|
|
33
|
+
# Services::SendEmail::Service.call_async(
|
|
34
|
+
# user_id: 123,
|
|
35
|
+
# template: :welcome
|
|
36
|
+
# )
|
|
37
|
+
#
|
|
38
|
+
# @example With delay
|
|
39
|
+
# Services::SendReminder::Service.call_async(
|
|
40
|
+
# wait: 1.day,
|
|
41
|
+
# user_id: 123
|
|
42
|
+
# )
|
|
43
|
+
#
|
|
44
|
+
# @example With queue and priority
|
|
45
|
+
# Services::ProcessPayment::Service.call_async(
|
|
46
|
+
# queue: :critical,
|
|
47
|
+
# priority: 10,
|
|
48
|
+
# order_id: 456
|
|
49
|
+
# )
|
|
50
|
+
#
|
|
51
|
+
# @example With custom job options
|
|
52
|
+
# Services::GenerateReport::Service.call_async(
|
|
53
|
+
# wait_until: Date.tomorrow.beginning_of_day,
|
|
54
|
+
# job_options: { tags: ['reports', 'daily'] },
|
|
55
|
+
# report_type: :sales
|
|
56
|
+
# )
|
|
57
|
+
#
|
|
58
|
+
# @note Only available when ActiveJob is loaded (typically in Rails applications)
|
|
59
|
+
# @see Servus::Base.call
|
|
28
60
|
def call_async(**args)
|
|
29
61
|
# Extract ActiveJob configuration options
|
|
30
62
|
job_options = args.slice(:wait, :wait_until, :queue, :priority)
|