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.
- 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 +46 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_helper'
|
|
2
4
|
|
|
3
5
|
RSpec.describe <%= service_full_class_name %> do
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
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
|
|
24
|
-
|
|
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
|
-
|
|
31
|
-
@
|
|
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
|
-
# # => "/
|
|
65
|
+
# # => "/full/path/app/schemas/process_payment/arguments.json"
|
|
46
66
|
def schema_path_for(service_namespace, type)
|
|
47
|
-
File.join(
|
|
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
|
-
# # => "/
|
|
77
|
+
# # => "/full/path/app/schemas/process_payment"
|
|
58
78
|
def schema_dir_for(service_namespace)
|
|
59
|
-
File.join(
|
|
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
|