servus 0.3.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/generators/servus/guard/guard_generator.rb +1 -1
- data/lib/generators/servus/guard/templates/guard.rb.erb +5 -3
- data/lib/generators/servus/service/service_generator.rb +1 -1
- data/lib/servus/base.rb +46 -3
- data/lib/servus/config.rb +85 -12
- data/lib/servus/event.rb +235 -0
- data/lib/servus/events/bus.rb +111 -72
- data/lib/servus/events/class_router.rb +40 -0
- data/lib/servus/events/emitter.rb +21 -6
- data/lib/servus/events/invocation.rb +94 -0
- data/lib/servus/events/router.rb +44 -0
- data/lib/servus/guard.rb +7 -6
- data/lib/servus/guards/falsey_guard.rb +3 -3
- data/lib/servus/guards/presence_guard.rb +4 -4
- data/lib/servus/guards/state_guard.rb +4 -5
- data/lib/servus/guards/truthy_guard.rb +3 -3
- data/lib/servus/helpers/controller_helpers.rb +40 -0
- data/lib/servus/railtie.rb +10 -8
- data/lib/servus/support/errors.rb +16 -0
- data/lib/servus/support/lockdown.rb +94 -0
- data/lib/servus/support/logger.rb +18 -0
- data/lib/servus/support/validator.rb +70 -40
- data/lib/servus/testing/example_builders.rb +52 -0
- data/lib/servus/testing/matchers.rb +103 -4
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +7 -2
- metadata +14 -116
- data/.claude/commands/check-docs.md +0 -1
- data/.claude/commands/consistency-check.md +0 -1
- data/.claude/commands/fine-tooth-comb.md +0 -1
- data/.claude/commands/red-green-refactor.md +0 -5
- data/.claude/settings.json +0 -24
- data/.rspec +0 -3
- data/.rubocop.yml +0 -27
- data/.yardopts +0 -6
- data/CHANGELOG.md +0 -169
- data/CLAUDE.md +0 -10
- data/IDEAS.md +0 -5
- data/LICENSE.txt +0 -21
- data/READme.md +0 -856
- data/Rakefile +0 -45
- data/docs/core/1_overview.md +0 -81
- data/docs/core/2_architecture.md +0 -120
- data/docs/core/3_service_objects.md +0 -154
- data/docs/features/1_schema_validation.md +0 -161
- data/docs/features/2_error_handling.md +0 -129
- data/docs/features/3_async_execution.md +0 -81
- data/docs/features/4_logging.md +0 -64
- data/docs/features/5_event_bus.md +0 -244
- data/docs/features/6_guards.md +0 -356
- data/docs/features/7_lazy_resolvers.md +0 -238
- data/docs/features/guards_naming_convention.md +0 -540
- data/docs/guides/1_common_patterns.md +0 -90
- data/docs/guides/2_migration_guide.md +0 -225
- data/docs/integration/1_configuration.md +0 -154
- data/docs/integration/2_testing.md +0 -304
- data/docs/integration/3_rails_integration.md +0 -99
- data/docs/yard/Servus/Base.html +0 -1645
- data/docs/yard/Servus/Config.html +0 -582
- data/docs/yard/Servus/Extensions/Async/Call.html +0 -400
- data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +0 -140
- data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +0 -154
- data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +0 -154
- data/docs/yard/Servus/Extensions/Async/Errors.html +0 -128
- data/docs/yard/Servus/Extensions/Async/Ext.html +0 -119
- data/docs/yard/Servus/Extensions/Async/Job.html +0 -310
- data/docs/yard/Servus/Extensions/Async.html +0 -141
- data/docs/yard/Servus/Extensions.html +0 -117
- data/docs/yard/Servus/Generators/ServiceGenerator.html +0 -261
- data/docs/yard/Servus/Generators.html +0 -115
- data/docs/yard/Servus/Helpers/ControllerHelpers.html +0 -457
- data/docs/yard/Servus/Helpers.html +0 -115
- data/docs/yard/Servus/Railtie.html +0 -134
- data/docs/yard/Servus/Support/Errors/AuthenticationError.html +0 -287
- data/docs/yard/Servus/Support/Errors/BadRequestError.html +0 -283
- data/docs/yard/Servus/Support/Errors/ForbiddenError.html +0 -284
- data/docs/yard/Servus/Support/Errors/InternalServerError.html +0 -283
- data/docs/yard/Servus/Support/Errors/NotFoundError.html +0 -284
- data/docs/yard/Servus/Support/Errors/ServiceError.html +0 -489
- data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +0 -290
- data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +0 -200
- data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +0 -288
- data/docs/yard/Servus/Support/Errors/ValidationError.html +0 -200
- data/docs/yard/Servus/Support/Errors.html +0 -140
- data/docs/yard/Servus/Support/Logger.html +0 -856
- data/docs/yard/Servus/Support/Rescuer/BlockContext.html +0 -585
- data/docs/yard/Servus/Support/Rescuer/CallOverride.html +0 -257
- data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +0 -343
- data/docs/yard/Servus/Support/Rescuer.html +0 -267
- data/docs/yard/Servus/Support/Response.html +0 -574
- data/docs/yard/Servus/Support/Validator.html +0 -1150
- data/docs/yard/Servus/Support.html +0 -119
- data/docs/yard/Servus/Testing/ExampleBuilders.html +0 -523
- data/docs/yard/Servus/Testing/ExampleExtractor.html +0 -578
- data/docs/yard/Servus/Testing.html +0 -142
- data/docs/yard/Servus.html +0 -343
- data/docs/yard/_index.html +0 -535
- data/docs/yard/class_list.html +0 -54
- data/docs/yard/css/common.css +0 -1
- data/docs/yard/css/full_list.css +0 -58
- data/docs/yard/css/style.css +0 -503
- data/docs/yard/file.1_common_patterns.html +0 -154
- data/docs/yard/file.1_configuration.html +0 -115
- data/docs/yard/file.1_overview.html +0 -142
- data/docs/yard/file.1_schema_validation.html +0 -188
- data/docs/yard/file.2_architecture.html +0 -157
- data/docs/yard/file.2_error_handling.html +0 -190
- data/docs/yard/file.2_migration_guide.html +0 -242
- data/docs/yard/file.2_testing.html +0 -227
- data/docs/yard/file.3_async_execution.html +0 -145
- data/docs/yard/file.3_rails_integration.html +0 -160
- data/docs/yard/file.3_service_objects.html +0 -191
- data/docs/yard/file.4_logging.html +0 -135
- data/docs/yard/file.ErrorHandling.html +0 -190
- data/docs/yard/file.READme.html +0 -674
- data/docs/yard/file.architecture.html +0 -157
- data/docs/yard/file.async_execution.html +0 -145
- data/docs/yard/file.common_patterns.html +0 -154
- data/docs/yard/file.configuration.html +0 -115
- data/docs/yard/file.error_handling.html +0 -190
- data/docs/yard/file.logging.html +0 -135
- data/docs/yard/file.migration_guide.html +0 -242
- data/docs/yard/file.overview.html +0 -142
- data/docs/yard/file.rails_integration.html +0 -160
- data/docs/yard/file.schema_validation.html +0 -188
- data/docs/yard/file.service_objects.html +0 -191
- data/docs/yard/file.testing.html +0 -227
- data/docs/yard/file_list.html +0 -119
- data/docs/yard/frames.html +0 -22
- data/docs/yard/index.html +0 -674
- data/docs/yard/js/app.js +0 -344
- data/docs/yard/js/full_list.js +0 -242
- data/docs/yard/js/jquery.js +0 -4
- data/docs/yard/method_list.html +0 -542
- data/docs/yard/top-level-namespace.html +0 -110
- 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
data/lib/servus/events/bus.rb
CHANGED
|
@@ -2,96 +2,140 @@
|
|
|
2
2
|
|
|
3
3
|
module Servus
|
|
4
4
|
module Events
|
|
5
|
-
#
|
|
5
|
+
# Central event bus for registering Event classes and dispatching
|
|
6
|
+
# events through configured routers.
|
|
6
7
|
#
|
|
7
|
-
# The Bus
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# The Bus maintains a registry mapping event names to their Event
|
|
9
|
+
# class definitions (one-to-one). On +emit+, it delegates to the
|
|
10
|
+
# configured routers to resolve invocations, deduplicates by key,
|
|
11
|
+
# and executes. ActiveSupport::Notifications wraps the dispatch
|
|
12
|
+
# cycle for the +subscribe_all+ hook (e.g. forwarding to Eventus).
|
|
10
13
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# @example Registering a handler
|
|
15
|
-
# class UserCreatedHandler < Servus::EventHandler
|
|
16
|
-
# handles :user_created
|
|
14
|
+
# @example Registering an event class
|
|
15
|
+
# class UserCreated < Servus::Event
|
|
16
|
+
# event_name :user_created
|
|
17
17
|
# end
|
|
18
|
+
# # Registration happens automatically via the event_name DSL
|
|
18
19
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# @example Retrieving handlers for an event
|
|
22
|
-
# handlers = Servus::Events::Bus.handlers_for(:user_created)
|
|
23
|
-
# handlers.each { |handler| handler.handle(payload) }
|
|
20
|
+
# @example Emitting an event
|
|
21
|
+
# Bus.emit(:user_created, { user_id: 123 })
|
|
24
22
|
#
|
|
25
|
-
# @example
|
|
26
|
-
# Bus.
|
|
27
|
-
#
|
|
23
|
+
# @example Forwarding all events to an external system
|
|
24
|
+
# Bus.subscribe_all do |event_name, payload, started_at:, **|
|
|
25
|
+
# ExternalForwarder.perform_later(event: event_name, payload:)
|
|
26
|
+
# end
|
|
28
27
|
#
|
|
29
|
-
# @see Servus::
|
|
28
|
+
# @see Servus::Event
|
|
29
|
+
# @see Servus::Events::Router
|
|
30
|
+
# @see Servus::Events::ClassRouter
|
|
30
31
|
class Bus
|
|
31
32
|
class << self
|
|
32
|
-
# Registers
|
|
33
|
+
# Registers an Event class for a specific event name.
|
|
33
34
|
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# automatically subscribed to ActiveSupport::Notifications.
|
|
35
|
+
# Each event name maps to exactly one Event class. Attempting to
|
|
36
|
+
# register a second class for the same name raises an error.
|
|
37
37
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
38
|
+
# Event classes are typically registered automatically at boot time
|
|
39
|
+
# via the +event_name+ DSL method or +ensure_registered!+.
|
|
40
40
|
#
|
|
41
|
-
# @param
|
|
42
|
-
# @param
|
|
43
|
-
# @return [
|
|
41
|
+
# @param name [Symbol] the event name
|
|
42
|
+
# @param event_class [Class] the Event subclass to register
|
|
43
|
+
# @return [void]
|
|
44
|
+
# @raise [RuntimeError] if the event name is already registered
|
|
44
45
|
#
|
|
45
46
|
# @example
|
|
46
|
-
# Bus.
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
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)
|
|
47
|
+
# Bus.register_event(:user_created, UserCreated)
|
|
48
|
+
def register_event(name, event_class)
|
|
49
|
+
if events.key?(name)
|
|
50
|
+
raise "Event :#{name} is already registered to #{events[name]}. Cannot register #{event_class}"
|
|
55
51
|
end
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
subscriptions[event_name] ||= []
|
|
59
|
-
subscriptions[event_name] << subscription
|
|
53
|
+
events[name] = event_class
|
|
60
54
|
end
|
|
61
55
|
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
# Returns a duplicate array to prevent external modification of the
|
|
65
|
-
# internal handler registry.
|
|
56
|
+
# Returns the Event class registered for the given name.
|
|
66
57
|
#
|
|
67
|
-
# @param
|
|
68
|
-
# @return [
|
|
58
|
+
# @param name [Symbol] the event name
|
|
59
|
+
# @return [Class, nil] the Event class or nil if not registered
|
|
69
60
|
#
|
|
70
61
|
# @example
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
def
|
|
74
|
-
|
|
62
|
+
# event_class = Bus.event_for(:user_created)
|
|
63
|
+
# event_class.invocations_for(payload)
|
|
64
|
+
def event_for(name)
|
|
65
|
+
events[name]
|
|
75
66
|
end
|
|
76
67
|
|
|
77
|
-
# Emits an event
|
|
68
|
+
# Emits an event through the configured routers.
|
|
78
69
|
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
70
|
+
# Collects invocations from all routers (in config array order),
|
|
71
|
+
# deduplicates by key (first wins), and executes each. The entire
|
|
72
|
+
# dispatch is wrapped in ActiveSupport::Notifications so that
|
|
73
|
+
# +subscribe_all+ listeners receive timing information.
|
|
82
74
|
#
|
|
83
75
|
# @param event_name [Symbol] the name of the event to emit
|
|
84
|
-
# @param payload [Hash] the event payload
|
|
76
|
+
# @param payload [Hash] the event payload
|
|
85
77
|
# @return [void]
|
|
86
78
|
#
|
|
87
79
|
# @example
|
|
88
80
|
# Bus.emit(:user_created, { user_id: 123, email: 'user@example.com' })
|
|
89
81
|
# # Rails log: servus.events.user_created (1.2ms) {:user_id=>123, :email=>"user@example.com"}
|
|
90
82
|
def emit(event_name, payload)
|
|
91
|
-
ActiveSupport::Notifications.instrument(notification_name(event_name), payload)
|
|
83
|
+
ActiveSupport::Notifications.instrument(notification_name(event_name), payload) do
|
|
84
|
+
resolve_invocations(event_name, payload)
|
|
85
|
+
.uniq(&:key)
|
|
86
|
+
.each(&:execute)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Subscribes to all Servus event emissions.
|
|
91
|
+
#
|
|
92
|
+
# Yields the clean event name and payload as positional args, plus
|
|
93
|
+
# +started_at+, +finished_at+, and +id+ as keyword args.
|
|
94
|
+
# Returns the subscription for manual unsubscribe.
|
|
95
|
+
#
|
|
96
|
+
# @yield [event_name, payload, started_at:, finished_at:, id:]
|
|
97
|
+
# @yieldparam event_name [Symbol] the event name
|
|
98
|
+
# @yieldparam payload [Hash] the event payload
|
|
99
|
+
# @yieldparam started_at [Time] when the event was emitted
|
|
100
|
+
# @yieldparam finished_at [Time] when the instrumented block completed
|
|
101
|
+
# @yieldparam id [String] unique notification ID
|
|
102
|
+
# @return [Object] the ActiveSupport::Notifications subscription
|
|
103
|
+
#
|
|
104
|
+
# @example Forward all events to an external system
|
|
105
|
+
# Servus::Events::Bus.subscribe_all do |event_name, payload, started_at:, **|
|
|
106
|
+
# EventusForwardJob.perform_later(
|
|
107
|
+
# event: event_name.to_s,
|
|
108
|
+
# payload: payload.as_json,
|
|
109
|
+
# occurred_at: started_at.utc.iso8601(6)
|
|
110
|
+
# )
|
|
111
|
+
# end
|
|
112
|
+
def subscribe_all(&block)
|
|
113
|
+
ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, started, finished, id, payload|
|
|
114
|
+
event_name = name.delete_prefix('servus.events.').to_sym
|
|
115
|
+
block.call(event_name, payload, started_at: started, finished_at: finished, id: id)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Installs the internal event logger. Called once at boot via
|
|
120
|
+
# the Railtie. Logs every event emission with its AS::Notifications
|
|
121
|
+
# correlation ID and dispatch duration.
|
|
122
|
+
#
|
|
123
|
+
# Multiple +subscribe_all+ subscriptions coexist — the app can
|
|
124
|
+
# add its own (e.g. for Eventus forwarding) independently.
|
|
125
|
+
#
|
|
126
|
+
# @return [void]
|
|
127
|
+
def enable_logging!
|
|
128
|
+
return if @logging_enabled
|
|
129
|
+
|
|
130
|
+
subscribe_all do |event_name, payload, **meta|
|
|
131
|
+
duration_ms = (meta[:finished_at] - meta[:started_at]) * 1000
|
|
132
|
+
Servus::Support::Logger.log_event(event_name, payload, event_id: meta[:id], duration_ms:)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
@logging_enabled = true
|
|
92
136
|
end
|
|
93
137
|
|
|
94
|
-
# Clears all registered
|
|
138
|
+
# Clears all registered events.
|
|
95
139
|
#
|
|
96
140
|
# Useful for testing and development mode reloading.
|
|
97
141
|
#
|
|
@@ -100,28 +144,23 @@ module Servus
|
|
|
100
144
|
# @example
|
|
101
145
|
# Bus.clear
|
|
102
146
|
def clear
|
|
103
|
-
|
|
104
|
-
ActiveSupport::Notifications.unsubscribe(subscription)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
@handlers = nil
|
|
108
|
-
@subscriptions = nil
|
|
147
|
+
@events = nil
|
|
109
148
|
end
|
|
110
149
|
|
|
111
150
|
private
|
|
112
151
|
|
|
113
|
-
#
|
|
152
|
+
# Collects invocations from all configured routers.
|
|
114
153
|
#
|
|
115
|
-
# @
|
|
116
|
-
|
|
117
|
-
|
|
154
|
+
# @param event_name [Symbol] the event name
|
|
155
|
+
# @param payload [Hash] the event payload
|
|
156
|
+
# @return [Array<Servus::Events::Invocation>]
|
|
157
|
+
def resolve_invocations(event_name, payload)
|
|
158
|
+
Servus.config.routers.flat_map { |router| router.resolve(event_name, payload) }
|
|
118
159
|
end
|
|
119
160
|
|
|
120
|
-
# Hash
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def subscriptions
|
|
124
|
-
@subscriptions ||= {}
|
|
161
|
+
# @return [Hash{Symbol => Class}] event name to Event class mapping
|
|
162
|
+
def events
|
|
163
|
+
@events ||= {}
|
|
125
164
|
end
|
|
126
165
|
|
|
127
166
|
# Converts an event name to a namespaced notification name.
|
|
@@ -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
|
|
|
@@ -127,6 +127,7 @@ module Servus
|
|
|
127
127
|
def emit_events_for(trigger, result)
|
|
128
128
|
self.class.emissions_for(trigger).each do |emission|
|
|
129
129
|
payload = build_event_payload(emission, result)
|
|
130
|
+
validate_event_payload!(emission[:event_name], payload)
|
|
130
131
|
Servus::Events::Bus.emit(emission[:event_name], payload)
|
|
131
132
|
end
|
|
132
133
|
end
|
|
@@ -134,6 +135,20 @@ module Servus
|
|
|
134
135
|
# Instance methods for emitting events during service execution
|
|
135
136
|
private
|
|
136
137
|
|
|
138
|
+
# Validates the payload against the Event class's schema registered for the event.
|
|
139
|
+
#
|
|
140
|
+
# @param event_name [Symbol] the event name
|
|
141
|
+
# @param payload [Hash] the event payload
|
|
142
|
+
# @return [void]
|
|
143
|
+
# @raise [Servus::Support::Errors::ValidationError] if payload fails validation
|
|
144
|
+
# @api private
|
|
145
|
+
def validate_event_payload!(event_name, payload)
|
|
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
|
+
end
|
|
151
|
+
|
|
137
152
|
# Builds the event payload using the configured payload builder or defaults.
|
|
138
153
|
#
|
|
139
154
|
# @param emission [Hash] the emission configuration
|
|
@@ -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/guard.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Servus
|
|
|
19
19
|
# }
|
|
20
20
|
# end
|
|
21
21
|
#
|
|
22
|
-
# def test
|
|
22
|
+
# def test
|
|
23
23
|
# account.balance >= amount
|
|
24
24
|
# end
|
|
25
25
|
# end
|
|
@@ -52,7 +52,7 @@ module Servus
|
|
|
52
52
|
# Servus::Guard.execute!(EnsurePositive, amount: -10) # throws :guard_failure
|
|
53
53
|
def execute!(guard_class, **)
|
|
54
54
|
guard = guard_class.new(**)
|
|
55
|
-
return if guard.test
|
|
55
|
+
return if guard.test
|
|
56
56
|
|
|
57
57
|
throw(:guard_failure, guard.error)
|
|
58
58
|
end
|
|
@@ -69,7 +69,7 @@ module Servus
|
|
|
69
69
|
# Servus::Guard.execute?(EnsurePositive, amount: 100) # => true
|
|
70
70
|
# Servus::Guard.execute?(EnsurePositive, amount: -10) # => false
|
|
71
71
|
def execute?(guard_class, **)
|
|
72
|
-
guard_class.new(**).test
|
|
72
|
+
guard_class.new(**).test
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
# Declares the HTTP status code for API responses.
|
|
@@ -218,14 +218,15 @@ module Servus
|
|
|
218
218
|
|
|
219
219
|
# Tests whether the guard passes.
|
|
220
220
|
#
|
|
221
|
-
# Subclasses must implement this method
|
|
222
|
-
#
|
|
221
|
+
# Subclasses must implement this zero-argument method. Arguments passed
|
|
222
|
+
# to `new` are stored as `@kwargs` and exposed via `method_missing`, so
|
|
223
|
+
# `test` reads them directly off the instance.
|
|
223
224
|
#
|
|
224
225
|
# @return [Boolean] true if the guard passes, false otherwise
|
|
225
226
|
# @raise [NotImplementedError] if not implemented by subclass
|
|
226
227
|
#
|
|
227
228
|
# @example
|
|
228
|
-
# def test
|
|
229
|
+
# def test
|
|
229
230
|
# account.balance >= amount
|
|
230
231
|
# end
|
|
231
232
|
def test
|
|
@@ -24,10 +24,10 @@ module Servus
|
|
|
24
24
|
|
|
25
25
|
# Tests whether all specified attributes are falsey.
|
|
26
26
|
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
27
|
+
# Reads `on` and `check` from the kwargs stored at initialization.
|
|
28
|
+
#
|
|
29
29
|
# @return [Boolean] true if all attributes are falsey
|
|
30
|
-
def test
|
|
30
|
+
def test
|
|
31
31
|
Array(check).all? { |attr| !on.public_send(attr) }
|
|
32
32
|
end
|
|
33
33
|
|
|
@@ -36,12 +36,12 @@ module Servus
|
|
|
36
36
|
# Tests whether all provided values are present.
|
|
37
37
|
#
|
|
38
38
|
# A value is considered present if it is not nil and not empty
|
|
39
|
-
# (for values that respond to empty?).
|
|
39
|
+
# (for values that respond to empty?). Reads all values from the
|
|
40
|
+
# kwargs stored at initialization.
|
|
40
41
|
#
|
|
41
|
-
# @param values [Hash] keyword arguments to validate
|
|
42
42
|
# @return [Boolean] true if all values are present
|
|
43
|
-
def test
|
|
44
|
-
|
|
43
|
+
def test
|
|
44
|
+
kwargs.all? { |_, value| present?(value) }
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
private
|
|
@@ -24,12 +24,11 @@ module Servus
|
|
|
24
24
|
|
|
25
25
|
# Tests whether the attribute matches the expected value(s).
|
|
26
26
|
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# @param is [Object, Array] expected value(s) - passes if attribute matches any
|
|
27
|
+
# Reads `on`, `check`, and `is` from the kwargs stored at initialization.
|
|
28
|
+
#
|
|
30
29
|
# @return [Boolean] true if attribute matches expected value(s)
|
|
31
|
-
def test
|
|
32
|
-
Array(is).include?(on.public_send(check))
|
|
30
|
+
def test
|
|
31
|
+
Array(kwargs[:is]).include?(on.public_send(check))
|
|
33
32
|
end
|
|
34
33
|
|
|
35
34
|
private
|
|
@@ -24,10 +24,10 @@ module Servus
|
|
|
24
24
|
|
|
25
25
|
# Tests whether all specified attributes are truthy.
|
|
26
26
|
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
27
|
+
# Reads `on` and `check` from the kwargs stored at initialization.
|
|
28
|
+
#
|
|
29
29
|
# @return [Boolean] true if all attributes are truthy
|
|
30
|
-
def test
|
|
30
|
+
def test
|
|
31
31
|
Array(check).all? { |attr| !!on.public_send(attr) }
|
|
32
32
|
end
|
|
33
33
|
|
|
@@ -39,6 +39,46 @@ module Servus
|
|
|
39
39
|
render_service_error(@result.error) unless @result.success?
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Executes a service and returns its data on success, raising the
|
|
43
|
+
# failure's error otherwise.
|
|
44
|
+
#
|
|
45
|
+
# The bang counterpart to {#run_service}. Use it outside a standard
|
|
46
|
+
# controller render flow — inside background logic, callbacks, or any
|
|
47
|
+
# place where a failure should propagate as an exception rather than be
|
|
48
|
+
# rendered as JSON.
|
|
49
|
+
#
|
|
50
|
+
# Inside a service's `#call` method, use {Servus::Base#call!} instead —
|
|
51
|
+
# it preserves the failure Response for the outer service's caller rather
|
|
52
|
+
# than raising.
|
|
53
|
+
#
|
|
54
|
+
# Mirrors {#run_service}: stores the full Response in @result so views
|
|
55
|
+
# and downstream helpers can reach for it the same way, then returns the
|
|
56
|
+
# data on success or raises on failure. The only behavioural difference
|
|
57
|
+
# between the two is raise-vs-render on failure.
|
|
58
|
+
#
|
|
59
|
+
# Sugar over:
|
|
60
|
+
#
|
|
61
|
+
# @result = Service.call(**params)
|
|
62
|
+
# raise @result.error unless @result.success?
|
|
63
|
+
# data = @result.data
|
|
64
|
+
#
|
|
65
|
+
# @example From a rake task
|
|
66
|
+
# data = run_service!(Treasury::Reconcile::Service, date: Date.current)
|
|
67
|
+
#
|
|
68
|
+
# @param klass [Class<Servus::Base>] service class to execute
|
|
69
|
+
# @param params [Hash] keyword arguments to pass to the service
|
|
70
|
+
# @return [Servus::Support::DataObject, Object] the service's data on success
|
|
71
|
+
# @raise [Servus::Support::Errors::ServiceError] the failure's error otherwise
|
|
72
|
+
#
|
|
73
|
+
# @see #run_service
|
|
74
|
+
# @see Servus::Base#call!
|
|
75
|
+
def run_service!(klass, **params)
|
|
76
|
+
@result = klass.call(**params)
|
|
77
|
+
return @result.data if @result.success?
|
|
78
|
+
|
|
79
|
+
raise @result.error
|
|
80
|
+
end
|
|
81
|
+
|
|
42
82
|
# Renders a service error as a JSON response.
|
|
43
83
|
#
|
|
44
84
|
# Uses error.http_status for the response status code and
|