servus 0.1.4 → 0.1.6

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 (39) 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/CHANGELOG.md +46 -0
  9. data/CLAUDE.md +10 -0
  10. data/IDEAS.md +1 -1
  11. data/READme.md +153 -5
  12. data/builds/servus-0.1.4.gem +0 -0
  13. data/builds/servus-0.1.5.gem +0 -0
  14. data/docs/core/2_architecture.md +32 -4
  15. data/docs/current_focus.md +569 -0
  16. data/docs/features/5_event_bus.md +244 -0
  17. data/docs/integration/1_configuration.md +60 -7
  18. data/docs/integration/2_testing.md +123 -0
  19. data/lib/generators/servus/event_handler/event_handler_generator.rb +59 -0
  20. data/lib/generators/servus/event_handler/templates/handler.rb.erb +86 -0
  21. data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +48 -0
  22. data/lib/generators/servus/service/service_generator.rb +4 -0
  23. data/lib/generators/servus/service/templates/arguments.json.erb +19 -10
  24. data/lib/generators/servus/service/templates/result.json.erb +8 -2
  25. data/lib/generators/servus/service/templates/service.rb.erb +101 -4
  26. data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
  27. data/lib/servus/base.rb +21 -5
  28. data/lib/servus/config.rb +34 -14
  29. data/lib/servus/event_handler.rb +275 -0
  30. data/lib/servus/events/bus.rb +137 -0
  31. data/lib/servus/events/emitter.rb +162 -0
  32. data/lib/servus/events/errors.rb +10 -0
  33. data/lib/servus/railtie.rb +16 -0
  34. data/lib/servus/support/validator.rb +27 -0
  35. data/lib/servus/testing/matchers.rb +88 -0
  36. data/lib/servus/testing.rb +2 -0
  37. data/lib/servus/version.rb +1 -1
  38. data/lib/servus.rb +6 -0
  39. metadata +19 -1
@@ -1,4 +1,10 @@
1
1
  {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "<%= service_class_name %> Result",
4
+ "description": "JSON Schema for validating <%= service_class_name %> result data",
2
5
  "type": "object",
3
- "properties": {}
4
- }
6
+ "properties": {
7
+ },
8
+ "required": [],
9
+ "additionalProperties": true
10
+ }
@@ -1,12 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ <%- unless options[:no_docs] -%>
4
+ # Performs <%= file_name.humanize.downcase %> operations.
5
+ #
6
+ # Services encapsulate business logic with automatic validation, logging,
7
+ # benchmarking, and error handling. They return Response objects indicating
8
+ # success or failure.
9
+ #
10
+ # @example Basic usage
11
+ # result = <%= service_full_class_name %>.call(<%= parameters.empty? ? '' : parameters.map { |p| "#{p}: value" }.join(', ') %>)
12
+ #
13
+ # if result.success?
14
+ # result.data # => { ... }
15
+ # else
16
+ # result.error.message # => "Error description"
17
+ # end
18
+ #
19
+ <%- unless parameters.empty? -%>
20
+ # @example With all parameters
21
+ # result = <%= service_full_class_name %>.call(
22
+ <%- parameters.each do |param| -%>
23
+ # <%= param %>: <%= param %>_value<%= param == parameters.last ? '' : ',' %>
24
+ <%- end -%>
25
+ # )
26
+ #
27
+ <%- end -%>
28
+ # @example Async execution (via ActiveJob)
29
+ # <%= service_full_class_name %>.call_async(<%= parameters.empty? ? '' : parameters.map { |p| "#{p}: value" }.join(', ') %>)
30
+ #
31
+ # @example With event emission
32
+ # class <%= service_class_name %> < Servus::Base
33
+ # emits :completed, on: :success
34
+ # emits :failed, on: :failure
35
+ # # ...
36
+ # end
37
+ #
38
+ # @see Servus::Base
39
+ # @see Servus::Support::Response
40
+ <%- end -%>
1
41
  module <%= class_name %>
2
42
  class Service < Servus::Base
43
+ <%- unless options[:no_docs] -%>
44
+ # TODO: Define argument validation schema (optional but recommended)
45
+ # schema arguments: {
46
+ # type: 'object',
47
+ <%- if parameters.any? -%>
48
+ # required: [<%= parameters.map { |p| "'#{p}'" }.join(', ') %>],
49
+ <%- else -%>
50
+ # required: [],
51
+ <%- end -%>
52
+ # properties: {
53
+ <%- parameters.each do |param| -%>
54
+ # <%= param %>: { type: 'string' }<%= param == parameters.last ? '' : ',' %>
55
+ <%- end -%>
56
+ <%- if parameters.empty? -%>
57
+ # # property_name: { type: 'string' }
58
+ <%- end -%>
59
+ # }
60
+ # }
61
+
62
+ # TODO: Define result validation schema (optional)
63
+ # schema result: {
64
+ # type: 'object',
65
+ # required: [],
66
+ # properties: {
67
+ # # result_field: { type: 'string' }
68
+ # }
69
+ # }
70
+
71
+ <%- end -%>
72
+ <%- unless options[:no_docs] -%>
73
+ # Initializes the service with required parameters.
74
+ #
75
+ <%- parameters.each do |param| -%>
76
+ # @param <%= param %> [Object] TODO: document this parameter
77
+ <%- end -%>
78
+ <%- if parameters.empty? -%>
79
+ # @param args [Hash] service arguments
80
+ <%- end -%>
81
+ <%- end -%>
3
82
  def initialize<%= parameter_list %>
4
- <%= initialize_params %>
83
+ <%= initialize_params.empty? ? '# TODO: Initialize instance variables' : initialize_params %>
5
84
  end
85
+
86
+ <%- unless options[:no_docs] -%>
87
+ # Executes the service logic.
88
+ #
89
+ # @return [Servus::Support::Response] success or failure response
90
+ #
91
+ # @example Returning success
92
+ # success({ user_id: 123, status: 'active' })
93
+ #
94
+ # @example Returning failure
95
+ # failure('Something went wrong')
96
+ #
97
+ # @example Returning typed failure
98
+ # failure('Not found', type: Servus::Support::Errors::NotFoundError)
99
+ <%- end -%>
6
100
  def call
7
- # Implement service logic here
101
+ # TODO: Implement service logic here
8
102
  success({})
9
103
  end
10
- <%= attr_readers %>
104
+
105
+ private
106
+
107
+ <%= attr_readers.empty? ? '# TODO: Add attr_readers for instance variables' : attr_readers %>
11
108
  end
12
- end
109
+ end
@@ -1,9 +1,70 @@
1
- require "rails_helper"
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
2
4
 
3
5
  RSpec.describe <%= service_full_class_name %> do
4
- describe "#call" do
5
- it "does something" do
6
- # Add expectations here
7
- end
6
+ <%- unless options[:no_docs] -%>
7
+ # Test the service by calling it with valid arguments and asserting on the result.
8
+ #
9
+ # Example test patterns:
10
+ #
11
+ # it 'returns success with expected data' do
12
+ # result = described_class.call(<%= parameters.empty? ? '' : parameters.map { |p| "#{p}: #{p}_value" }.join(', ') %>)
13
+ #
14
+ # expect(result).to be_success
15
+ # expect(result.data).to include(expected_key: expected_value)
16
+ # end
17
+ #
18
+ # it 'returns failure when validation fails' do
19
+ # result = described_class.call(<%= parameters.empty? ? '' : parameters.map { |p| "#{p}: invalid_value" }.join(', ') %>)
20
+ #
21
+ # expect(result).to be_failure
22
+ # expect(result.error.message).to eq('Expected error message')
23
+ # end
24
+ #
25
+ # For testing async execution:
26
+ # it 'enqueues the service job' do
27
+ # expect {
28
+ # described_class.call_async(<%= parameters.empty? ? '' : parameters.map { |p| "#{p}: #{p}_value" }.join(', ') %>)
29
+ # }.to have_enqueued_job(Servus::ServiceJob)
30
+ # end
31
+ #
32
+ # For testing event emissions:
33
+ # include Servus::Testing::EventHelpers
34
+ #
35
+ # it 'emits expected event on success' do
36
+ # servus_expect_event(:event_name)
37
+ # .with_payload(hash_including(key: value))
38
+ # .when { described_class.call(<%= parameters.empty? ? '' : parameters.map { |p| "#{p}: value" }.join(', ') %>) }
39
+ # end
40
+
41
+ <%- end -%>
42
+ <%- if parameters.any? -%>
43
+ let(:<%= parameters.first %>) { nil } # TODO: Set valid test value
44
+ <%- parameters[1..-1]&.each do |param| -%>
45
+ let(:<%= param %>) { nil } # TODO: Set valid test value
46
+ <%- end -%>
47
+
48
+ <%- end -%>
49
+ describe '#call' do
50
+ <%- unless options[:no_docs] -%>
51
+ # TODO: Add success case test
52
+ # it 'returns success with expected data' do
53
+ # result = described_class.call(<%= parameters.empty? ? '' : parameters.map { |p| "#{p}: #{p}" }.join(', ') %>)
54
+ #
55
+ # expect(result).to be_success
56
+ # expect(result.data).to include(key: value)
57
+ # end
58
+
59
+ # TODO: Add failure case test
60
+ # it 'returns failure when conditions are not met' do
61
+ # result = described_class.call(<%= parameters.empty? ? '' : parameters.map { |p| "#{p}: invalid_#{p}" }.join(', ') %>)
62
+ #
63
+ # expect(result).to be_failure
64
+ # expect(result.error.message).to eq('Expected error')
65
+ # end
66
+ <%- else -%>
67
+ # Add your tests here
68
+ <%- end -%>
8
69
  end
9
- end
70
+ end
data/lib/servus/base.rb CHANGED
@@ -48,9 +48,11 @@ module Servus
48
48
  class Base
49
49
  include Servus::Support::Errors
50
50
  include Servus::Support::Rescuer
51
+ include Servus::Events::Emitter
51
52
 
52
53
  # Support class aliases
53
54
  Logger = Servus::Support::Logger
55
+ Emitter = Servus::Events::Emitter
54
56
  Response = Servus::Support::Response
55
57
  Validator = Servus::Support::Validator
56
58
 
@@ -138,7 +140,12 @@ module Servus
138
140
  # @note Prefer {#failure} for expected error conditions. Use this for exceptional cases.
139
141
  # @see #failure
140
142
  def error!(message = nil, type: Servus::Support::Errors::ServiceError)
141
- Logger.log_exception(self.class, type.new(message))
143
+ error = type.new(message)
144
+ Logger.log_exception(self.class, error)
145
+
146
+ # Emit error! events before raising
147
+ emit_events_for(:error!, Response.new(false, nil, error))
148
+
142
149
  raise type, message
143
150
  end
144
151
 
@@ -172,10 +179,15 @@ module Servus
172
179
  #
173
180
  # @see #initialize
174
181
  # @see #call
182
+ #
183
+ # rubocop:disable Metrics/MethodLength
175
184
  def call(**args)
176
185
  before_call(args)
177
- result = benchmark(**args) { new(**args).call }
178
- after_call(result)
186
+
187
+ instance = new(**args)
188
+ result = benchmark(**args) { instance.call }
189
+
190
+ after_call(result, instance)
179
191
 
180
192
  result
181
193
  rescue Servus::Support::Errors::ValidationError => e
@@ -185,6 +197,7 @@ module Servus
185
197
  Logger.log_exception(self, e)
186
198
  raise e
187
199
  end
200
+ # rubocop:enable Metrics/MethodLength
188
201
 
189
202
  # Defines schema validation rules for the service's arguments and/or result.
190
203
  #
@@ -257,18 +270,21 @@ module Servus
257
270
  Validator.validate_arguments!(self, args)
258
271
  end
259
272
 
260
- # Executes post-call hooks including result validation.
273
+ # Executes post-call hooks including result validation and event emission.
261
274
  #
262
275
  # This method is automatically called after service execution completes and handles:
263
276
  # - Validating the result data against RESULT_SCHEMA (if defined)
277
+ # - Emitting events declared with the emits DSL
264
278
  #
265
279
  # @param result [Servus::Support::Response] the response returned from the service
280
+ # @param instance [Servus::Base] the service instance
266
281
  # @return [void]
267
282
  # @raise [Servus::Support::Errors::ValidationError] if result data fails validation
268
283
  #
269
284
  # @api private
270
- def after_call(result)
285
+ def after_call(result, instance)
271
286
  Validator.validate_result!(self, result)
287
+ Emitter.emit_result_events!(instance, result)
272
288
  end
273
289
 
274
290
  # Measures service execution time and logs the result.
data/lib/servus/config.rb CHANGED
@@ -15,36 +15,56 @@ module Servus
15
15
  # @see Servus.config
16
16
  # @see Servus.configure
17
17
  class Config
18
- # The root directory where schema files are located.
18
+ # The directory where JSON schema files are located.
19
19
  #
20
- # Defaults to `Rails.root/app/schemas/services` in Rails applications,
21
- # or a relative path from the gem installation otherwise.
20
+ # Defaults to `Rails.root/app/schemas/services` in Rails applications.
22
21
  #
23
- # @return [String] the schema root directory path
24
- attr_reader :schema_root
22
+ # @return [String] the schemas directory path
23
+ attr_accessor :schemas_dir
24
+
25
+ # The directory where event handlers are located.
26
+ #
27
+ # Defaults to `Rails.root/app/events` in Rails applications.
28
+ #
29
+ # @return [String] the events directory path
30
+ attr_accessor :events_dir
31
+
32
+ # The directory where services are located.
33
+ #
34
+ # Defaults to `Rails.root/app/services` in Rails applications.
35
+ #
36
+ # @return [String] the services directory path
37
+ attr_accessor :services_dir
38
+
39
+ # Whether to validate that all event handlers subscribe to events that are actually emitted by services.
40
+ #
41
+ # When enabled, raises an error on boot if handlers subscribe to non-existent events.
42
+ # Helps catch typos and orphaned handlers.
43
+ #
44
+ # @return [Boolean] true to validate, false to skip validation
45
+ attr_accessor :strict_event_validation
25
46
 
26
47
  # Initializes a new configuration with default values.
27
48
  #
28
49
  # @api private
29
50
  def initialize
30
- # Default to Rails.root if available, otherwise use current working directory
31
- @schema_root = File.join(root_path, 'app/schemas/services')
51
+ @events_dir = 'app/events'
52
+ @schemas_dir = 'app/schemas'
53
+ @services_dir = 'app/services'
54
+ @strict_event_validation = true
32
55
  end
33
56
 
34
57
  # Returns the full path to a service's schema file.
35
58
  #
36
- # Constructs the path by combining {#schema_root} with the service namespace
37
- # and schema type.
38
- #
39
59
  # @param service_namespace [String] underscored service namespace (e.g., "process_payment")
40
60
  # @param type [String] schema type ("arguments" or "result")
41
61
  # @return [String] full path to the schema JSON file
42
62
  #
43
63
  # @example
44
64
  # config.schema_path_for("process_payment", "arguments")
45
- # # => "/app/app/schemas/services/process_payment/arguments.json"
65
+ # # => "/full/path/app/schemas/process_payment/arguments.json"
46
66
  def schema_path_for(service_namespace, type)
47
- File.join(schema_root.to_s, service_namespace, "#{type}.json")
67
+ File.join(root_path, schemas_dir, service_namespace, "#{type}.json")
48
68
  end
49
69
 
50
70
  # Returns the directory containing a service's schema files.
@@ -54,9 +74,9 @@ module Servus
54
74
  #
55
75
  # @example
56
76
  # config.schema_dir_for("process_payment")
57
- # # => "/app/app/schemas/services/process_payment"
77
+ # # => "/full/path/app/schemas/process_payment"
58
78
  def schema_dir_for(service_namespace)
59
- File.join(schema_root.to_s, service_namespace)
79
+ File.join(root_path, schemas_dir, service_namespace)
60
80
  end
61
81
 
62
82
  private
@@ -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