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,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
- # Calls the service asynchronously using AsyncCallerJob.
6
+ # Provides asynchronous service execution via ActiveJob.
7
7
  #
8
- # Supports all standard ActiveJob scheduling and routing options:
9
- # - wait: <ActiveSupport::Duration> (e.g., 5.minutes)
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
- # @param args [Hash] The arguments to pass to the service and job options.
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)