servus 0.4.0 → 0.5.0
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/lib/generators/servus/event/event_generator.rb +54 -0
- data/lib/generators/servus/event/templates/event.rb.erb +44 -0
- data/lib/generators/servus/event/templates/event_spec.rb.erb +20 -0
- data/lib/servus/config.rb +18 -13
- data/lib/servus/event.rb +235 -0
- data/lib/servus/events/bus.rb +82 -72
- data/lib/servus/events/class_router.rb +40 -0
- data/lib/servus/events/emitter.rb +11 -11
- data/lib/servus/events/invocation.rb +94 -0
- data/lib/servus/events/router.rb +44 -0
- data/lib/servus/railtie.rb +10 -8
- data/lib/servus/support/errors.rb +1 -1
- data/lib/servus/support/logger.rb +5 -3
- data/lib/servus/support/validator.rb +8 -9
- data/lib/servus/testing/matchers.rb +5 -5
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +6 -2
- metadata +9 -7
- data/lib/generators/servus/event_handler/event_handler_generator.rb +0 -59
- data/lib/generators/servus/event_handler/templates/handler.rb.erb +0 -86
- data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +0 -48
- data/lib/servus/event_handler.rb +0 -290
- data/lib/servus/events/errors.rb +0 -10
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Events
|
|
5
|
+
# Default router that reads +invoke+ declarations from Event classes.
|
|
6
|
+
#
|
|
7
|
+
# ClassRouter is what ships with Servus and is the default when no
|
|
8
|
+
# routers are configured. It resolves invocations by looking up all
|
|
9
|
+
# Event classes registered for the given event name and calling
|
|
10
|
+
# +invocations_for+ on each — which evaluates if/unless conditions
|
|
11
|
+
# and returns Invocation objects for actions that should run.
|
|
12
|
+
#
|
|
13
|
+
# Applications can add additional routers (e.g. a data-driven router
|
|
14
|
+
# backed by a database table) alongside the ClassRouter:
|
|
15
|
+
#
|
|
16
|
+
# Servus.configure do |config|
|
|
17
|
+
# config.routers = [
|
|
18
|
+
# Servus::Events::ClassRouter.new,
|
|
19
|
+
# MyApp::DataDrivenRouter.new
|
|
20
|
+
# ]
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @see Servus::Events::Router
|
|
24
|
+
# @see Servus::Event#invocations_for
|
|
25
|
+
class ClassRouter < Router
|
|
26
|
+
# Resolves invocations by reading +invoke+ declarations from all
|
|
27
|
+
# Event classes registered for the given event name.
|
|
28
|
+
#
|
|
29
|
+
# @param event_name [Symbol] the name of the emitted event
|
|
30
|
+
# @param payload [Hash] the event payload
|
|
31
|
+
# @return [Array<Servus::Events::Invocation>] invocations to execute
|
|
32
|
+
def resolve(event_name, payload)
|
|
33
|
+
event_class = Bus.event_for(event_name)
|
|
34
|
+
return [] unless event_class
|
|
35
|
+
|
|
36
|
+
event_class.invocations_for(payload)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -69,24 +69,24 @@ module Servus
|
|
|
69
69
|
#
|
|
70
70
|
# @note Best Practice: Services should typically emit ONE event per trigger
|
|
71
71
|
# that represents their core concern. Multiple downstream reactions should
|
|
72
|
-
# be coordinated by
|
|
72
|
+
# be coordinated by Event classes, not by emitting multiple events
|
|
73
73
|
# from the service. This maintains separation of concerns.
|
|
74
74
|
#
|
|
75
|
-
# @example Recommended pattern (one event, multiple
|
|
75
|
+
# @example Recommended pattern (one event, multiple reactions)
|
|
76
76
|
# # Service emits one event
|
|
77
77
|
# class CreateUser < Servus::Base
|
|
78
78
|
# emits :user_created, on: :success
|
|
79
79
|
# end
|
|
80
80
|
#
|
|
81
|
-
# #
|
|
82
|
-
# class
|
|
83
|
-
#
|
|
81
|
+
# # Event coordinates multiple reactions
|
|
82
|
+
# class UserCreated < Servus::Event
|
|
83
|
+
# event_name :user_created
|
|
84
84
|
# invoke SendWelcomeEmail::Service, async: true
|
|
85
85
|
# invoke TrackAnalytics::Service, async: true
|
|
86
86
|
# end
|
|
87
87
|
#
|
|
88
88
|
# @see Servus::Events::Bus
|
|
89
|
-
# @see Servus::
|
|
89
|
+
# @see Servus::Event
|
|
90
90
|
def emits(event_name, on:, with: nil, &block)
|
|
91
91
|
valid_triggers = %i[success failure error!]
|
|
92
92
|
|
|
@@ -128,7 +128,6 @@ module Servus
|
|
|
128
128
|
self.class.emissions_for(trigger).each do |emission|
|
|
129
129
|
payload = build_event_payload(emission, result)
|
|
130
130
|
validate_event_payload!(emission[:event_name], payload)
|
|
131
|
-
Servus::Support::Logger.log_event(emission[:event_name], payload)
|
|
132
131
|
Servus::Events::Bus.emit(emission[:event_name], payload)
|
|
133
132
|
end
|
|
134
133
|
end
|
|
@@ -136,7 +135,7 @@ module Servus
|
|
|
136
135
|
# Instance methods for emitting events during service execution
|
|
137
136
|
private
|
|
138
137
|
|
|
139
|
-
# Validates the payload against
|
|
138
|
+
# Validates the payload against the Event class's schema registered for the event.
|
|
140
139
|
#
|
|
141
140
|
# @param event_name [Symbol] the event name
|
|
142
141
|
# @param payload [Hash] the event payload
|
|
@@ -144,9 +143,10 @@ module Servus
|
|
|
144
143
|
# @raise [Servus::Support::Errors::ValidationError] if payload fails validation
|
|
145
144
|
# @api private
|
|
146
145
|
def validate_event_payload!(event_name, payload)
|
|
147
|
-
Servus::Events::Bus.
|
|
148
|
-
|
|
149
|
-
|
|
146
|
+
event_class = Servus::Events::Bus.event_for(event_name)
|
|
147
|
+
return unless event_class
|
|
148
|
+
|
|
149
|
+
Servus::Support::Validator.validate_event_payload!(event_class, payload)
|
|
150
150
|
end
|
|
151
151
|
|
|
152
152
|
# Builds the event payload using the configured payload builder or defaults.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Servus
|
|
7
|
+
module Events
|
|
8
|
+
# A normalized, executable representation of "call this service with
|
|
9
|
+
# these params."
|
|
10
|
+
#
|
|
11
|
+
# Routers return arrays of Invocation objects. The Bus collects them,
|
|
12
|
+
# deduplicates by +#key+ (first wins), and calls +#execute+ on each.
|
|
13
|
+
#
|
|
14
|
+
# An Invocation separates *identity* (service + params) from
|
|
15
|
+
# *execution strategy* (async, queue, priority, etc.). The +#key+
|
|
16
|
+
# is derived only from the identity — two invocations that call the
|
|
17
|
+
# same service with the same params are considered duplicates
|
|
18
|
+
# regardless of their options.
|
|
19
|
+
#
|
|
20
|
+
# @example Sync invocation
|
|
21
|
+
# Invocation.new(
|
|
22
|
+
# service: Rewards::Grant::Service,
|
|
23
|
+
# params: { user_id: "abc-123" },
|
|
24
|
+
# options: {}
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
# @example Async invocation with scheduling options
|
|
28
|
+
# Invocation.new(
|
|
29
|
+
# service: Notifications::Send::Service,
|
|
30
|
+
# params: { user_id: "abc-123" },
|
|
31
|
+
# options: { async: true, queue: :mailers, priority: 5 }
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
# @see Servus::Events::Router
|
|
35
|
+
# @see Servus::Events::Bus
|
|
36
|
+
class Invocation
|
|
37
|
+
# @return [Class] the service class to call (must respond to +.call+ or +.call_async+)
|
|
38
|
+
attr_reader :service
|
|
39
|
+
|
|
40
|
+
# @return [Hash] keyword arguments passed to the service
|
|
41
|
+
attr_reader :params
|
|
42
|
+
|
|
43
|
+
# @return [Hash] execution options — +async+, +queue+, +wait+,
|
|
44
|
+
# +wait_until+, +priority+, +job_options+
|
|
45
|
+
attr_reader :options
|
|
46
|
+
|
|
47
|
+
# @param service [Class] the service class
|
|
48
|
+
# @param params [Hash] keyword arguments for the service
|
|
49
|
+
# @param options [Hash] execution options
|
|
50
|
+
def initialize(service:, params:, options: {})
|
|
51
|
+
@service = service
|
|
52
|
+
@params = params
|
|
53
|
+
@options = options
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Executes the invocation.
|
|
57
|
+
#
|
|
58
|
+
# Delegates to +service.call+ for synchronous invocations or
|
|
59
|
+
# +service.call_async+ for asynchronous ones. Async scheduling
|
|
60
|
+
# options (queue, wait, priority, etc.) are merged into the
|
|
61
|
+
# call_async kwargs.
|
|
62
|
+
#
|
|
63
|
+
# @return [Servus::Support::Response, void]
|
|
64
|
+
def execute
|
|
65
|
+
if options[:async]
|
|
66
|
+
service.call_async(**params, **async_options)
|
|
67
|
+
else
|
|
68
|
+
service.call(**params)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A deterministic deduplication key derived from the service class
|
|
73
|
+
# and params. Two invocations with the same key are considered
|
|
74
|
+
# duplicates — the Bus keeps the first and skips the rest.
|
|
75
|
+
#
|
|
76
|
+
# Options are intentionally excluded: identity is *what* to call,
|
|
77
|
+
# not *how* to call it.
|
|
78
|
+
#
|
|
79
|
+
# @return [String] SHA-256 hex digest
|
|
80
|
+
def key
|
|
81
|
+
Digest::SHA256.hexdigest("#{service}:#{params.to_json}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Extracts scheduling options for +call_async+.
|
|
87
|
+
#
|
|
88
|
+
# @return [Hash]
|
|
89
|
+
def async_options
|
|
90
|
+
options.slice(:queue, :wait, :wait_until, :priority, :job_options).compact
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Events
|
|
5
|
+
# Abstract base class for event routers.
|
|
6
|
+
#
|
|
7
|
+
# Routers resolve which services should be invoked when an event fires.
|
|
8
|
+
# The Bus iterates all configured routers (in order), collects the
|
|
9
|
+
# Invocation objects they return, deduplicates by key, and executes.
|
|
10
|
+
#
|
|
11
|
+
# Servus ships one built-in router — ClassRouter — which reads the
|
|
12
|
+
# +invoke+ declarations from Event classes. Applications can add their
|
|
13
|
+
# own routers (e.g. a data-driven router backed by a database table)
|
|
14
|
+
# by subclassing Router and implementing +#resolve+.
|
|
15
|
+
#
|
|
16
|
+
# Configure routers in the Servus initializer:
|
|
17
|
+
#
|
|
18
|
+
# Servus.configure do |config|
|
|
19
|
+
# config.routers = [
|
|
20
|
+
# Servus::Events::ClassRouter.new,
|
|
21
|
+
# MyApp::DataDrivenRouter.new
|
|
22
|
+
# ]
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @see Servus::Events::ClassRouter
|
|
26
|
+
# @see Servus::Events::Invocation
|
|
27
|
+
# @see Servus::Events::Bus
|
|
28
|
+
class Router
|
|
29
|
+
# Resolves which service invocations should run for the given event.
|
|
30
|
+
#
|
|
31
|
+
# Implementations must evaluate any conditions (if/unless) internally
|
|
32
|
+
# and return only invocations that *will* run. The Bus does not
|
|
33
|
+
# perform further filtering.
|
|
34
|
+
#
|
|
35
|
+
# @param event_name [Symbol] the name of the emitted event
|
|
36
|
+
# @param payload [Hash] the event payload
|
|
37
|
+
# @return [Array<Servus::Events::Invocation>] invocations to execute
|
|
38
|
+
# @raise [NotImplementedError] when called on the abstract base class
|
|
39
|
+
def resolve(event_name, payload)
|
|
40
|
+
raise NotImplementedError, "#{self.class}#resolve must be implemented"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/servus/railtie.rb
CHANGED
|
@@ -25,7 +25,11 @@ module Servus
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
initializer 'servus.event_logging' do
|
|
29
|
+
Servus::Events::Bus.enable_logging!
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Load guards and event classes, clear caches on reload
|
|
29
33
|
config.to_prepare do
|
|
30
34
|
# Load custom guards from guards_dir
|
|
31
35
|
guards_path = Rails.root.join(Servus.config.guards_dir)
|
|
@@ -37,16 +41,14 @@ module Servus
|
|
|
37
41
|
|
|
38
42
|
Servus::Events::Bus.clear if Rails.env.development?
|
|
39
43
|
|
|
40
|
-
# Eager load all event
|
|
44
|
+
# Eager load all event classes
|
|
41
45
|
events_path = Rails.root.join(Servus.config.events_dir)
|
|
42
|
-
Dir[File.join(events_path, '**/*
|
|
46
|
+
Dir[File.join(events_path, '**/*_event.rb')].each do |file|
|
|
43
47
|
require_dependency file
|
|
44
48
|
end
|
|
45
|
-
end
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# Or create a rake task for CI validation.
|
|
50
|
+
# Infer and register event names for classes that didn't call event_name explicitly
|
|
51
|
+
Servus::Event.descendants.each(&:ensure_registered!)
|
|
52
|
+
end
|
|
51
53
|
end
|
|
52
54
|
end
|
|
@@ -267,7 +267,7 @@ module Servus
|
|
|
267
267
|
def api_error = { code: http_status, message: message }
|
|
268
268
|
end
|
|
269
269
|
|
|
270
|
-
# Raised when a service or
|
|
270
|
+
# Raised when a service or Event class is used without a required schema.
|
|
271
271
|
#
|
|
272
272
|
# Triggered by the +require_service_arguments_schema+,
|
|
273
273
|
# +require_service_result_schema+, or +require_event_payload_schema+
|
|
@@ -63,12 +63,14 @@ module Servus
|
|
|
63
63
|
logger.warn("#{service_class.name} guard failed: #{error.message}")
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
# Logs an event emission
|
|
66
|
+
# Logs an event emission with correlation ID and duration.
|
|
67
67
|
#
|
|
68
68
|
# @param event_name [Symbol] The event name
|
|
69
69
|
# @param payload [Hash] The event payload
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
# @param event_id [String] The unique event correlation ID
|
|
71
|
+
# @param duration_ms [Float] The dispatch duration in milliseconds
|
|
72
|
+
def self.log_event(event_name, payload, event_id:, duration_ms:)
|
|
73
|
+
logger.info("[#{event_id}] Event :#{event_name} (#{duration_ms.round(1)}ms) #{payload.inspect}")
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
# Logs a validation error from a service
|
|
@@ -102,27 +102,26 @@ module Servus
|
|
|
102
102
|
end
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
# Validates event payload against the
|
|
105
|
+
# Validates event payload against the Event class's payload schema.
|
|
106
106
|
#
|
|
107
|
-
# @param
|
|
107
|
+
# @param event_class [Class] the Event subclass
|
|
108
108
|
# @param payload [Hash] the event payload to validate
|
|
109
109
|
# @return [Boolean] true if validation passes
|
|
110
110
|
# @raise [Servus::Support::Errors::ValidationError] if payload fails validation
|
|
111
111
|
#
|
|
112
|
-
#
|
|
113
112
|
# @example
|
|
114
|
-
# Validator.validate_event_payload!(
|
|
113
|
+
# Validator.validate_event_payload!(UserCreated, { user_id: 123 })
|
|
115
114
|
#
|
|
116
115
|
# @api private
|
|
117
|
-
def self.validate_event_payload!(
|
|
118
|
-
schema =
|
|
119
|
-
enforce_schema_presence!(schema,
|
|
116
|
+
def self.validate_event_payload!(event_class, payload)
|
|
117
|
+
schema = event_class.payload_schema
|
|
118
|
+
enforce_schema_presence!(schema, event_class, :require_event_payload_schema)
|
|
120
119
|
return true unless schema
|
|
121
120
|
|
|
122
121
|
validate_data_against_schema!(
|
|
123
122
|
payload,
|
|
124
123
|
schema,
|
|
125
|
-
"Invalid payload for event :#{
|
|
124
|
+
"Invalid payload for event :#{event_class.event_name}"
|
|
126
125
|
)
|
|
127
126
|
|
|
128
127
|
true
|
|
@@ -210,7 +209,7 @@ module Servus
|
|
|
210
209
|
# Returns the schema if present. Raises if absent and the config flag is enabled.
|
|
211
210
|
#
|
|
212
211
|
# @param schema [Hash, nil] the loaded schema
|
|
213
|
-
# @param klass [Class] the service or
|
|
212
|
+
# @param klass [Class] the service or Event class
|
|
214
213
|
# @param config_flag [Symbol] the config method to check
|
|
215
214
|
# @return [Hash, nil] the schema
|
|
216
215
|
# @raise [Servus::Support::Errors::SchemaRequiredError] if schema is nil and enforcement is enabled
|
|
@@ -12,7 +12,7 @@ module Servus
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
# Matcher for asserting event emission
|
|
15
|
-
RSpec::Matchers.define :emit_event do |
|
|
15
|
+
RSpec::Matchers.define :emit_event do |event_class_or_symbol|
|
|
16
16
|
supports_block_expectations
|
|
17
17
|
|
|
18
18
|
chain :with do |payload|
|
|
@@ -30,10 +30,10 @@ RSpec::Matchers.define :emit_event do |handler_class_or_symbol|
|
|
|
30
30
|
block.call
|
|
31
31
|
|
|
32
32
|
# Determine event name
|
|
33
|
-
@event_name = if
|
|
34
|
-
|
|
33
|
+
@event_name = if event_class_or_symbol.is_a?(Symbol)
|
|
34
|
+
event_class_or_symbol
|
|
35
35
|
else
|
|
36
|
-
|
|
36
|
+
event_class_or_symbol.event_name
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
@matching_event = @captured_events.find { |e| e[:name] == @event_name }
|
|
@@ -86,7 +86,7 @@ RSpec::Matchers.define :call_service do |service_class|
|
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
# Matcher for asserting schema presence on a service or
|
|
89
|
+
# Matcher for asserting schema presence on a service or Event class
|
|
90
90
|
RSpec::Matchers.define :have_schema do |schema_type|
|
|
91
91
|
match do |klass|
|
|
92
92
|
if schema_type.to_s == 'payload'
|
data/lib/servus/version.rb
CHANGED
data/lib/servus.rb
CHANGED
|
@@ -29,10 +29,14 @@ require_relative 'servus/support/lockdown'
|
|
|
29
29
|
require_relative 'servus/support/message_resolver'
|
|
30
30
|
|
|
31
31
|
# Events
|
|
32
|
-
require_relative 'servus/events/errors'
|
|
33
32
|
require_relative 'servus/events/bus'
|
|
34
33
|
require_relative 'servus/events/emitter'
|
|
35
|
-
require_relative 'servus/
|
|
34
|
+
require_relative 'servus/event'
|
|
35
|
+
|
|
36
|
+
# Routing
|
|
37
|
+
require_relative 'servus/events/router'
|
|
38
|
+
require_relative 'servus/events/invocation'
|
|
39
|
+
require_relative 'servus/events/class_router'
|
|
36
40
|
|
|
37
41
|
# Guards (guards.rb loads defaults based on config)
|
|
38
42
|
require_relative 'servus/guard'
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: servus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Scholl
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: active_model_serializers
|
|
@@ -75,9 +75,9 @@ executables: []
|
|
|
75
75
|
extensions: []
|
|
76
76
|
extra_rdoc_files: []
|
|
77
77
|
files:
|
|
78
|
-
- lib/generators/servus/
|
|
79
|
-
- lib/generators/servus/
|
|
80
|
-
- lib/generators/servus/
|
|
78
|
+
- lib/generators/servus/event/event_generator.rb
|
|
79
|
+
- lib/generators/servus/event/templates/event.rb.erb
|
|
80
|
+
- lib/generators/servus/event/templates/event_spec.rb.erb
|
|
81
81
|
- lib/generators/servus/guard/guard_generator.rb
|
|
82
82
|
- lib/generators/servus/guard/templates/guard.rb.erb
|
|
83
83
|
- lib/generators/servus/guard/templates/guard_spec.rb.erb
|
|
@@ -89,10 +89,12 @@ files:
|
|
|
89
89
|
- lib/servus.rb
|
|
90
90
|
- lib/servus/base.rb
|
|
91
91
|
- lib/servus/config.rb
|
|
92
|
-
- lib/servus/
|
|
92
|
+
- lib/servus/event.rb
|
|
93
93
|
- lib/servus/events/bus.rb
|
|
94
|
+
- lib/servus/events/class_router.rb
|
|
94
95
|
- lib/servus/events/emitter.rb
|
|
95
|
-
- lib/servus/events/
|
|
96
|
+
- lib/servus/events/invocation.rb
|
|
97
|
+
- lib/servus/events/router.rb
|
|
96
98
|
- lib/servus/extensions/async/call.rb
|
|
97
99
|
- lib/servus/extensions/async/errors.rb
|
|
98
100
|
- lib/servus/extensions/async/ext.rb
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Servus
|
|
4
|
-
module Generators
|
|
5
|
-
# Rails generator for creating Servus event handlers.
|
|
6
|
-
#
|
|
7
|
-
# Generates an event handler class and spec file.
|
|
8
|
-
#
|
|
9
|
-
# @example Generate an event handler
|
|
10
|
-
# rails g servus:event_handler user_created
|
|
11
|
-
#
|
|
12
|
-
# @example Generated files
|
|
13
|
-
# app/events/user_created_handler.rb
|
|
14
|
-
# spec/app/events/user_created_handler_spec.rb
|
|
15
|
-
#
|
|
16
|
-
# @see https://guides.rubyonrails.org/generators.html
|
|
17
|
-
class EventHandlerGenerator < Rails::Generators::NamedBase
|
|
18
|
-
source_root File.expand_path('templates', __dir__)
|
|
19
|
-
|
|
20
|
-
class_option :no_docs, type: :boolean,
|
|
21
|
-
default: false,
|
|
22
|
-
desc: 'Skip documentation comments in generated files'
|
|
23
|
-
|
|
24
|
-
# Creates the event handler and spec files.
|
|
25
|
-
#
|
|
26
|
-
# @return [void]
|
|
27
|
-
def create_handler_file
|
|
28
|
-
template 'handler.rb.erb', handler_path
|
|
29
|
-
template 'handler_spec.rb.erb', handler_spec_path
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
private
|
|
33
|
-
|
|
34
|
-
# Returns the path for the handler file.
|
|
35
|
-
#
|
|
36
|
-
# @return [String] handler file path
|
|
37
|
-
# @api private
|
|
38
|
-
def handler_path
|
|
39
|
-
File.join(Servus.config.events_dir, "#{file_name}_handler.rb")
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Returns the path for the handler spec file.
|
|
43
|
-
#
|
|
44
|
-
# @return [String] spec file path
|
|
45
|
-
# @api private
|
|
46
|
-
def handler_spec_path
|
|
47
|
-
File.join(Servus.config.tests_dir, Servus.config.events_dir, "#{file_name}_handler_spec.rb")
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Returns the handler class name.
|
|
51
|
-
#
|
|
52
|
-
# @return [String] handler class name
|
|
53
|
-
# @api private
|
|
54
|
-
def handler_class_name
|
|
55
|
-
"#{class_name}Handler"
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
<%- unless options[:no_docs] -%>
|
|
4
|
-
# Handles the :<%= file_name %> event by invoking configured services.
|
|
5
|
-
#
|
|
6
|
-
# EventHandlers subscribe to a single event and declaratively map it to one or
|
|
7
|
-
# more service invocations. This provides clean separation between event emission
|
|
8
|
-
# (what happened) and event handling (what to do about it).
|
|
9
|
-
#
|
|
10
|
-
# @example Basic usage
|
|
11
|
-
# class <%= handler_class_name %> < Servus::EventHandler
|
|
12
|
-
# handles :<%= file_name %>
|
|
13
|
-
#
|
|
14
|
-
# # Invoke a service when this event fires
|
|
15
|
-
# invoke SendEmail::Service, async: true do |payload|
|
|
16
|
-
# { user_id: payload[:user_id], email: payload[:email] }
|
|
17
|
-
# end
|
|
18
|
-
# end
|
|
19
|
-
#
|
|
20
|
-
# @example Multiple service invocations
|
|
21
|
-
# invoke SendWelcomeEmail::Service, async: true, queue: :mailers do |payload|
|
|
22
|
-
# { user_id: payload[:user_id] }
|
|
23
|
-
# end
|
|
24
|
-
#
|
|
25
|
-
# invoke TrackAnalytics::Service, async: true do |payload|
|
|
26
|
-
# { event: '<%= file_name %>', user_id: payload[:user_id] }
|
|
27
|
-
# end
|
|
28
|
-
#
|
|
29
|
-
# @example Conditional invocation
|
|
30
|
-
# invoke GrantRewards::Service, if: ->(payload) { payload[:premium] } do |payload|
|
|
31
|
-
# { user_id: payload[:user_id] }
|
|
32
|
-
# end
|
|
33
|
-
#
|
|
34
|
-
# @example With payload schema validation
|
|
35
|
-
# schema payload: {
|
|
36
|
-
# type: 'object',
|
|
37
|
-
# required: ['user_id'],
|
|
38
|
-
# properties: {
|
|
39
|
-
# user_id: { type: 'integer' },
|
|
40
|
-
# email: { type: 'string', format: 'email' }
|
|
41
|
-
# }
|
|
42
|
-
# }
|
|
43
|
-
#
|
|
44
|
-
# @example Emit this event from anywhere
|
|
45
|
-
# # From controllers, jobs, rake tasks, etc.
|
|
46
|
-
# <%= handler_class_name %>.emit({ user_id: 123, email: 'user@example.com' })
|
|
47
|
-
#
|
|
48
|
-
# Available options for `invoke`:
|
|
49
|
-
# - async: true - Invoke service asynchronously via ActiveJob
|
|
50
|
-
# - queue: :queue_name - Specify ActiveJob queue (requires async: true)
|
|
51
|
-
# - if: ->(payload) {} - Condition that must be true to invoke
|
|
52
|
-
# - unless: ->(payload) {} - Condition that must be false to invoke
|
|
53
|
-
#
|
|
54
|
-
# @see Servus::EventHandler
|
|
55
|
-
# @see Servus::Events::Bus
|
|
56
|
-
<%- end -%>
|
|
57
|
-
class <%= handler_class_name %> < Servus::EventHandler
|
|
58
|
-
handles :<%= file_name %>
|
|
59
|
-
|
|
60
|
-
<%- unless options[:no_docs] -%>
|
|
61
|
-
# TODO: Define payload schema (optional but recommended)
|
|
62
|
-
# schema payload: {
|
|
63
|
-
# type: 'object',
|
|
64
|
-
# required: ['required_field'],
|
|
65
|
-
# properties: {
|
|
66
|
-
# required_field: { type: 'string' }
|
|
67
|
-
# }
|
|
68
|
-
# }
|
|
69
|
-
|
|
70
|
-
# TODO: Add service invocations
|
|
71
|
-
# invoke YourService, async: true do |payload|
|
|
72
|
-
# {
|
|
73
|
-
# # Map event payload to service arguments
|
|
74
|
-
# argument_name: payload[:field_name]
|
|
75
|
-
# }
|
|
76
|
-
# end
|
|
77
|
-
<%- end -%>
|
|
78
|
-
schema payload: {
|
|
79
|
-
type: 'object',
|
|
80
|
-
description: 'JSON schema for the <%= handler_class_name %> event payload',
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
# invoke ExampleService, async: true do |payload|
|
|
84
|
-
# { example_arg: payload[:example_field] }
|
|
85
|
-
# end
|
|
86
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'rails_helper'
|
|
4
|
-
|
|
5
|
-
RSpec.describe <%= handler_class_name %> do
|
|
6
|
-
<%- unless options[:no_docs] -%>
|
|
7
|
-
# Test that the handler invokes the correct services with properly mapped arguments.
|
|
8
|
-
#
|
|
9
|
-
# Example test pattern:
|
|
10
|
-
# it 'invokes YourService with correct arguments' do
|
|
11
|
-
# expect(YourService).to receive(:call_async).with(user_id: 123)
|
|
12
|
-
# described_class.handle({ user_id: 123, email: 'test@example.com' })
|
|
13
|
-
# end
|
|
14
|
-
#
|
|
15
|
-
# For testing event emission from controllers/jobs:
|
|
16
|
-
# include Servus::Testing::EventHelpers
|
|
17
|
-
#
|
|
18
|
-
# it 'emits <%= file_name %> event' do
|
|
19
|
-
# servus_expect_event(:<%= file_name %>)
|
|
20
|
-
# .with_payload(hash_including(user_id: 123))
|
|
21
|
-
# .when { YourController.new.create }
|
|
22
|
-
# end
|
|
23
|
-
|
|
24
|
-
<%- end -%>
|
|
25
|
-
let(:payload) do
|
|
26
|
-
{
|
|
27
|
-
# TODO: Add sample payload fields
|
|
28
|
-
# user_id: 123,
|
|
29
|
-
# email: 'test@example.com'
|
|
30
|
-
}
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
<%- unless options[:no_docs] -%>
|
|
34
|
-
# TODO: Add tests for service invocations
|
|
35
|
-
# it 'invokes YourService with mapped arguments' do
|
|
36
|
-
# expect(YourService).to receive(:call_async).with(user_id: payload[:user_id])
|
|
37
|
-
# described_class.handle(payload)
|
|
38
|
-
# end
|
|
39
|
-
|
|
40
|
-
# TODO: Test conditional logic if using :if or :unless
|
|
41
|
-
# it 'skips invocation when condition is false' do
|
|
42
|
-
# expect(YourService).not_to receive(:call_async)
|
|
43
|
-
# described_class.handle(payload.merge(premium: false))
|
|
44
|
-
# end
|
|
45
|
-
<%- else -%>
|
|
46
|
-
# Add your tests here
|
|
47
|
-
<%- end -%>
|
|
48
|
-
end
|