servus 0.6.0 → 1.0.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/templates/event.rb.erb +13 -10
- data/lib/generators/servus/service/service_generator.rb +7 -27
- data/lib/generators/servus/service/templates/service.rb.erb +17 -24
- data/lib/servus/base.rb +57 -103
- data/lib/servus/config.rb +42 -54
- data/lib/servus/event.rb +101 -46
- data/lib/servus/events/bus.rb +1 -1
- data/lib/servus/events/emitter.rb +48 -27
- data/lib/servus/events/errors.rb +56 -0
- data/lib/servus/events/invocation.rb +22 -28
- data/lib/servus/extensions/async/call.rb +7 -0
- data/lib/servus/helpers/controller_helpers.rb +0 -40
- data/lib/servus/railtie.rb +3 -0
- data/lib/servus/schema/cache.rb +70 -0
- data/lib/servus/schema/compiler.rb +196 -0
- data/lib/servus/schema/declaration.rb +148 -0
- data/lib/servus/schema/errors.rb +76 -0
- data/lib/servus/schema/path.rb +60 -0
- data/lib/servus/schema/ref.rb +87 -0
- data/lib/servus/schema.rb +281 -0
- data/lib/servus/support/logger.rb +29 -2
- data/lib/servus/support/validator.rb +50 -107
- data/lib/servus/testing/example_extractor.rb +4 -6
- data/lib/servus/testing/matchers.rb +1 -6
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +12 -0
- metadata +11 -5
- data/lib/generators/servus/service/templates/arguments.json.erb +0 -24
- data/lib/generators/servus/service/templates/result.json.erb +0 -10
data/lib/servus/event.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Servus
|
|
|
20
20
|
#
|
|
21
21
|
# schema payload: { type: 'object', required: ['user_id'] }
|
|
22
22
|
#
|
|
23
|
-
#
|
|
23
|
+
# enqueue SendWelcomeEmail::Service do |payload|
|
|
24
24
|
# { user_id: payload[:user_id] }
|
|
25
25
|
# end
|
|
26
26
|
# end
|
|
@@ -34,13 +34,49 @@ module Servus
|
|
|
34
34
|
# class AuditLogCreated < Servus::Event
|
|
35
35
|
# event_name :audit_log_created
|
|
36
36
|
#
|
|
37
|
-
#
|
|
37
|
+
# enqueue AuditLogger::Service
|
|
38
38
|
# end
|
|
39
39
|
#
|
|
40
40
|
# @see Servus::Events::Bus
|
|
41
41
|
# @see Servus::Events::Router
|
|
42
42
|
# @see Servus::Base
|
|
43
43
|
class Event
|
|
44
|
+
extend Servus::Schema::Declaration
|
|
45
|
+
|
|
46
|
+
# @!method self.schema(payload: nil)
|
|
47
|
+
# Declares the JSON schema for this event's payload.
|
|
48
|
+
#
|
|
49
|
+
# The payload is validated on every {Servus::Event.emit}. Schemas may
|
|
50
|
+
# reference shared fragments registered with {Servus::Schema.register};
|
|
51
|
+
# refs are resolved on first read.
|
|
52
|
+
#
|
|
53
|
+
# Omitting the keyword leaves any schema declared earlier — or by a
|
|
54
|
+
# superclass — in place. Passing it explicitly as +nil+ raises.
|
|
55
|
+
#
|
|
56
|
+
# @param payload [Hash] JSON schema for the event payload
|
|
57
|
+
# @return [void]
|
|
58
|
+
# @raise [ArgumentError] on an unknown keyword or an explicit nil
|
|
59
|
+
#
|
|
60
|
+
# @example
|
|
61
|
+
# class UserCreated < Servus::Event
|
|
62
|
+
# event_name :user_created
|
|
63
|
+
#
|
|
64
|
+
# schema payload: {
|
|
65
|
+
# type: 'object',
|
|
66
|
+
# required: ['user_id', 'email'],
|
|
67
|
+
# properties: {
|
|
68
|
+
# user_id: { type: 'integer' },
|
|
69
|
+
# email: { type: 'string', format: 'email' }
|
|
70
|
+
# }
|
|
71
|
+
# }
|
|
72
|
+
# end
|
|
73
|
+
#
|
|
74
|
+
# @see Servus::Schema
|
|
75
|
+
#
|
|
76
|
+
# @!method self.payload_schema
|
|
77
|
+
# @return [Hash, nil] the compiled payload schema
|
|
78
|
+
declare_schemas :payload
|
|
79
|
+
|
|
44
80
|
class << self
|
|
45
81
|
# Declares or returns the event name.
|
|
46
82
|
#
|
|
@@ -89,37 +125,50 @@ module Servus
|
|
|
89
125
|
event_name(name.demodulize.underscore.to_sym)
|
|
90
126
|
end
|
|
91
127
|
|
|
92
|
-
# Declares a service
|
|
128
|
+
# Declares a service to enqueue in response to the event.
|
|
93
129
|
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
130
|
+
# An event can declare as many services as it needs; each is enqueued
|
|
131
|
+
# independently when the event fires. The block maps the event payload to
|
|
132
|
+
# the service's keyword arguments — without one, the full payload is passed
|
|
133
|
+
# through.
|
|
96
134
|
#
|
|
97
|
-
#
|
|
135
|
+
# Invocation is always asynchronous. A reaction that ran inline would put
|
|
136
|
+
# its latency and its failures back into the emitting service, which is
|
|
137
|
+
# what events exist to avoid. This requires ActiveJob; see
|
|
138
|
+
# {Servus::Events::Errors::AsyncBackendMissingError}.
|
|
139
|
+
#
|
|
140
|
+
# @param service_class [Class] the service to enqueue (must inherit from Servus::Base)
|
|
98
141
|
# @param options [Hash] invocation options
|
|
99
|
-
# @option options [
|
|
100
|
-
# @option options [
|
|
101
|
-
# @option options [
|
|
102
|
-
# @option options [
|
|
142
|
+
# @option options [Symbol] :queue the queue to route the job to
|
|
143
|
+
# @option options [ActiveSupport::Duration] :wait delay before the job runs
|
|
144
|
+
# @option options [Time] :wait_until absolute time to run the job
|
|
145
|
+
# @option options [Integer] :priority job priority (adapter-dependent)
|
|
146
|
+
# @option options [Hash] :job_options additional ActiveJob options
|
|
147
|
+
# @option options [Proc] :if condition that must return true to enqueue
|
|
148
|
+
# @option options [Proc] :unless condition that must return false to enqueue
|
|
103
149
|
# @yield [payload] block that maps event payload to service arguments
|
|
104
150
|
# @yieldparam payload [Hash] the event payload
|
|
105
151
|
# @yieldreturn [Hash] keyword arguments for the service's initialize method
|
|
106
152
|
# @return [void]
|
|
153
|
+
# @raise [ArgumentError] if the removed +async:+ option is passed
|
|
107
154
|
#
|
|
108
|
-
# @example
|
|
109
|
-
#
|
|
155
|
+
# @example Enqueue a service
|
|
156
|
+
# enqueue SendEmail::Service do |payload|
|
|
110
157
|
# { user_id: payload[:user_id], email: payload[:email] }
|
|
111
158
|
# end
|
|
112
159
|
#
|
|
113
|
-
# @example
|
|
114
|
-
#
|
|
160
|
+
# @example Route to a queue
|
|
161
|
+
# enqueue SendEmail::Service, queue: :mailers do |payload|
|
|
115
162
|
# { user_id: payload[:user_id] }
|
|
116
163
|
# end
|
|
117
164
|
#
|
|
118
|
-
# @example Conditional
|
|
119
|
-
#
|
|
165
|
+
# @example Conditional
|
|
166
|
+
# enqueue GrantRewards::Service, if: ->(p) { p[:premium] } do |payload|
|
|
120
167
|
# { user_id: payload[:user_id] }
|
|
121
168
|
# end
|
|
122
|
-
def
|
|
169
|
+
def enqueue(service_class, options = {}, &block)
|
|
170
|
+
reject_async_option!(options)
|
|
171
|
+
|
|
123
172
|
@invocations ||= []
|
|
124
173
|
@invocations << {
|
|
125
174
|
service_class: service_class,
|
|
@@ -128,6 +177,20 @@ module Servus
|
|
|
128
177
|
}
|
|
129
178
|
end
|
|
130
179
|
|
|
180
|
+
# Explains that +invoke+ was renamed, rather than failing as a typo.
|
|
181
|
+
#
|
|
182
|
+
# Event classes load at boot, so a bare NoMethodError here would read like
|
|
183
|
+
# a misspelling instead of a rename. This covers both changes at once,
|
|
184
|
+
# since the overwhelmingly common declaration was +invoke Foo, async: true+.
|
|
185
|
+
#
|
|
186
|
+
# @raise [NoMethodError] always
|
|
187
|
+
# @deprecated Use {#enqueue}.
|
|
188
|
+
def invoke(*_args, **_options, &)
|
|
189
|
+
raise NoMethodError,
|
|
190
|
+
'`invoke` was renamed to `enqueue` in 1.0.0 — event invocation is always ' \
|
|
191
|
+
'asynchronous. Replace `invoke` with `enqueue`, and drop `async:` if present.'
|
|
192
|
+
end
|
|
193
|
+
|
|
131
194
|
# Returns all service invocations declared for this event.
|
|
132
195
|
#
|
|
133
196
|
# @return [Array<Hash>] array of invocation configurations
|
|
@@ -135,34 +198,6 @@ module Servus
|
|
|
135
198
|
@invocations || []
|
|
136
199
|
end
|
|
137
200
|
|
|
138
|
-
# Defines the JSON schema for validating event payloads.
|
|
139
|
-
#
|
|
140
|
-
# @param payload [Hash, nil] JSON schema for validating event payloads
|
|
141
|
-
# @return [void]
|
|
142
|
-
#
|
|
143
|
-
# @example
|
|
144
|
-
# class UserCreated < Servus::Event
|
|
145
|
-
# event_name :user_created
|
|
146
|
-
#
|
|
147
|
-
# schema payload: {
|
|
148
|
-
# type: 'object',
|
|
149
|
-
# required: ['user_id', 'email'],
|
|
150
|
-
# properties: {
|
|
151
|
-
# user_id: { type: 'integer' },
|
|
152
|
-
# email: { type: 'string', format: 'email' }
|
|
153
|
-
# }
|
|
154
|
-
# }
|
|
155
|
-
# end
|
|
156
|
-
def schema(payload: nil)
|
|
157
|
-
@payload_schema = payload.with_indifferent_access if payload
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# Returns the payload schema.
|
|
161
|
-
#
|
|
162
|
-
# @return [Hash, nil] the payload schema or nil if not defined
|
|
163
|
-
# @api private
|
|
164
|
-
attr_reader :payload_schema
|
|
165
|
-
|
|
166
201
|
# Emits this event via the Bus.
|
|
167
202
|
#
|
|
168
203
|
# Provides a type-safe, discoverable way to emit events from anywhere in
|
|
@@ -218,11 +253,31 @@ module Servus
|
|
|
218
253
|
# @param payload [Hash] the event payload
|
|
219
254
|
# @return [Array] results from all invoked services
|
|
220
255
|
def handle(payload)
|
|
221
|
-
invocations_for(payload).map(&:
|
|
256
|
+
invocations_for(payload).map(&:enqueue)
|
|
222
257
|
end
|
|
223
258
|
|
|
224
259
|
private
|
|
225
260
|
|
|
261
|
+
# Rejects the removed +async:+ option at declaration time.
|
|
262
|
+
#
|
|
263
|
+
# Declaration time matters here: an Event class loads at boot, so this
|
|
264
|
+
# fails on deploy rather than on the first emit in production. Rejecting
|
|
265
|
+
# +async: false+ is the point — that declaration asks for synchronous
|
|
266
|
+
# invocation, which no longer exists, and quietly giving it the opposite
|
|
267
|
+
# would be worse than refusing.
|
|
268
|
+
#
|
|
269
|
+
# @param options [Hash]
|
|
270
|
+
# @return [void]
|
|
271
|
+
# @raise [ArgumentError] if +:async+ is present, whatever its value
|
|
272
|
+
# @api private
|
|
273
|
+
def reject_async_option!(options)
|
|
274
|
+
return unless options.key?(:async)
|
|
275
|
+
|
|
276
|
+
raise ArgumentError,
|
|
277
|
+
'`async:` is no longer a valid option — event invocation is always ' \
|
|
278
|
+
'asynchronous. Remove it from the declaration.'
|
|
279
|
+
end
|
|
280
|
+
|
|
226
281
|
# @api private
|
|
227
282
|
def should_invoke?(payload, options)
|
|
228
283
|
return false if options[:if] && !options[:if].call(payload)
|
data/lib/servus/events/bus.rb
CHANGED
|
@@ -15,6 +15,15 @@ module Servus
|
|
|
15
15
|
module Emitter
|
|
16
16
|
extend ActiveSupport::Concern
|
|
17
17
|
|
|
18
|
+
# Triggers accepted by the +emits+ DSL.
|
|
19
|
+
#
|
|
20
|
+
# +:success+ and +:failure+ are selected from the service's result after
|
|
21
|
+
# +call+ returns. +:error!+ is fired by {Servus::Base#error!} immediately
|
|
22
|
+
# before it raises, so it never coincides with +:failure+.
|
|
23
|
+
#
|
|
24
|
+
# Note the bang on +:error!+ — it mirrors the method that triggers it.
|
|
25
|
+
EMISSION_TRIGGERS = %i[success failure error!].freeze
|
|
26
|
+
|
|
18
27
|
# Emits events for a service result.
|
|
19
28
|
#
|
|
20
29
|
# Called automatically after service execution completes. Determines the
|
|
@@ -33,7 +42,7 @@ module Servus
|
|
|
33
42
|
# Declares an event that this service will emit.
|
|
34
43
|
#
|
|
35
44
|
# Events are automatically emitted when the service completes with the specified
|
|
36
|
-
# trigger condition (:success, :failure, or :error). Use the `with` option or a
|
|
45
|
+
# trigger condition (:success, :failure, or :error!). Use the `with` option or a
|
|
37
46
|
# block to provide a custom payload builder. Use `if` or `unless` to gate emission
|
|
38
47
|
# on a runtime condition.
|
|
39
48
|
#
|
|
@@ -86,34 +95,21 @@ module Servus
|
|
|
86
95
|
# end
|
|
87
96
|
# end
|
|
88
97
|
#
|
|
89
|
-
# @
|
|
90
|
-
# that represents their core concern. Multiple downstream reactions should
|
|
91
|
-
# be coordinated by Event classes, not by emitting multiple events
|
|
92
|
-
# from the service. This maintains separation of concerns.
|
|
93
|
-
#
|
|
94
|
-
# @example Recommended pattern (one event, multiple reactions)
|
|
95
|
-
# # Service emits one event
|
|
98
|
+
# @example Multiple events on one trigger, each with its own payload
|
|
96
99
|
# class CreateUser < Servus::Base
|
|
97
100
|
# emits :user_created, on: :success
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
# # Event coordinates multiple reactions
|
|
101
|
-
# class UserCreated < Servus::Event
|
|
102
|
-
# event_name :user_created
|
|
103
|
-
# invoke SendWelcomeEmail::Service, async: true
|
|
104
|
-
# invoke TrackAnalytics::Service, async: true
|
|
101
|
+
# emits :welcome_queued, on: :success, with: :welcome_payload
|
|
105
102
|
# end
|
|
106
103
|
#
|
|
107
104
|
# @see Servus::Events::Bus
|
|
108
105
|
# @see Servus::Event
|
|
109
106
|
def emits(event_name, on:, **options, &block)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
raise ArgumentError, "Invalid trigger: #{on}. Must be one of: #{valid_triggers.join(', ')}"
|
|
107
|
+
unless EMISSION_TRIGGERS.include?(on)
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"Invalid trigger: #{on}. Must be one of: #{EMISSION_TRIGGERS.join(', ')}"
|
|
114
110
|
end
|
|
115
111
|
|
|
116
|
-
@event_emissions ||=
|
|
112
|
+
@event_emissions ||= empty_emissions
|
|
117
113
|
@event_emissions[on] << build_emission(event_name, options, block)
|
|
118
114
|
end
|
|
119
115
|
|
|
@@ -121,20 +117,25 @@ module Servus
|
|
|
121
117
|
#
|
|
122
118
|
# @return [Hash] hash of event emissions grouped by trigger
|
|
123
119
|
# { success: [...], failure: [...], error!: [...] }
|
|
124
|
-
def event_emissions
|
|
125
|
-
@event_emissions || { success: [], failure: [], error!: [] }
|
|
126
|
-
end
|
|
120
|
+
def event_emissions = @event_emissions || empty_emissions
|
|
127
121
|
|
|
128
122
|
# Returns event emissions for a specific trigger.
|
|
129
123
|
#
|
|
130
124
|
# @param trigger [Symbol] the trigger type (:success, :failure, :error!)
|
|
131
125
|
# @return [Array<Hash>] array of event configurations for this trigger
|
|
132
|
-
def emissions_for(trigger)
|
|
133
|
-
event_emissions[trigger] || []
|
|
134
|
-
end
|
|
126
|
+
def emissions_for(trigger) = event_emissions[trigger] || []
|
|
135
127
|
|
|
136
128
|
private
|
|
137
129
|
|
|
130
|
+
# An empty emission set, one entry per supported trigger.
|
|
131
|
+
#
|
|
132
|
+
# Derived from {Emitter::EMISSION_TRIGGERS} rather than written out, so
|
|
133
|
+
# the shape cannot drift from the list of triggers actually accepted.
|
|
134
|
+
#
|
|
135
|
+
# @return [Hash{Symbol => Array}]
|
|
136
|
+
# @api private
|
|
137
|
+
def empty_emissions = EMISSION_TRIGGERS.to_h { |trigger| [trigger, []] }
|
|
138
|
+
|
|
138
139
|
def build_emission(event_name, options, block)
|
|
139
140
|
{
|
|
140
141
|
event_name: event_name,
|
|
@@ -201,11 +202,31 @@ module Servus
|
|
|
201
202
|
# @api private
|
|
202
203
|
def validate_event_payload!(event_name, payload)
|
|
203
204
|
event_class = Servus::Events::Bus.event_for(event_name)
|
|
204
|
-
return unless event_class
|
|
205
|
+
return require_event_schema!(event_name) unless event_class
|
|
205
206
|
|
|
206
207
|
Servus::Support::Validator.validate_event_payload!(event_class, payload)
|
|
207
208
|
end
|
|
208
209
|
|
|
210
|
+
# Enforces {Servus::Config#require_event_payload_schema} for an event that
|
|
211
|
+
# has no Event class to carry a schema.
|
|
212
|
+
#
|
|
213
|
+
# An unregistered event name is the one case where a payload cannot be
|
|
214
|
+
# validated at all, so it is exactly where the flag matters most. Skipping
|
|
215
|
+
# it here would mean the setting silently passed over the events furthest
|
|
216
|
+
# from having a contract.
|
|
217
|
+
#
|
|
218
|
+
# @param event_name [Symbol] the event being emitted
|
|
219
|
+
# @return [void]
|
|
220
|
+
# @raise [Servus::Support::Errors::SchemaRequiredError] if enforcement is enabled
|
|
221
|
+
# @api private
|
|
222
|
+
def require_event_schema!(event_name)
|
|
223
|
+
return unless Servus.config.require_event_payload_schema
|
|
224
|
+
|
|
225
|
+
raise Servus::Support::Errors::SchemaRequiredError,
|
|
226
|
+
"#{self.class} emits :#{event_name} but no Event class is registered for it — " \
|
|
227
|
+
'schema missing! require_event_payload_schema is set to true.'
|
|
228
|
+
end
|
|
229
|
+
|
|
209
230
|
# Builds the event payload using the configured payload builder or defaults.
|
|
210
231
|
#
|
|
211
232
|
# @param emission [Hash] the emission configuration
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Events
|
|
5
|
+
# Errors raised while resolving or enqueueing event invocations.
|
|
6
|
+
#
|
|
7
|
+
# These deliberately do *not* inherit from {Servus::Support::Errors::ServiceError}.
|
|
8
|
+
# Everything in that hierarchy carries an +#http_status+ and an +#api_error+
|
|
9
|
+
# because it describes a business outcome a caller might render. A missing job
|
|
10
|
+
# backend, or a service that cannot be enqueued, is a configuration problem —
|
|
11
|
+
# there is no sensible HTTP status for it.
|
|
12
|
+
#
|
|
13
|
+
# @see Servus::Events::Invocation
|
|
14
|
+
module Errors
|
|
15
|
+
# Base class for every event invocation error.
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Raised when an event invocation cannot be enqueued because ActiveJob is
|
|
19
|
+
# not loaded.
|
|
20
|
+
#
|
|
21
|
+
# Event invocation is always asynchronous, so an event that reacts to
|
|
22
|
+
# anything needs a job backend. Servus's core — services, schemas, guards,
|
|
23
|
+
# and the bus itself — works without one; only +enqueue+ declarations
|
|
24
|
+
# require it.
|
|
25
|
+
class AsyncBackendMissingError < Error
|
|
26
|
+
# @param service [Class] the service that could not be enqueued
|
|
27
|
+
# @return [AsyncBackendMissingError]
|
|
28
|
+
def self.for(service)
|
|
29
|
+
new(
|
|
30
|
+
"Cannot enqueue #{service} from an event: ActiveJob is not loaded. " \
|
|
31
|
+
'Event invocation is always asynchronous and runs through ActiveJob. ' \
|
|
32
|
+
'Require active_job, or remove the enqueue declaration.'
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Raised when a service has no name, so no job class can be generated for it.
|
|
38
|
+
#
|
|
39
|
+
# ActiveJob resolves a job on the worker by its serialized class name, so a
|
|
40
|
+
# service created with +Class.new(Servus::Base)+ has nothing to serialize.
|
|
41
|
+
# This surfaces almost exclusively in tests — assign the class to a constant,
|
|
42
|
+
# or use +stub_const+.
|
|
43
|
+
class AnonymousServiceError < Error
|
|
44
|
+
# @param service [Class] the anonymous service
|
|
45
|
+
# @return [AnonymousServiceError]
|
|
46
|
+
def self.for(service)
|
|
47
|
+
new(
|
|
48
|
+
"Cannot generate a job class for #{service.inspect}: it is anonymous. " \
|
|
49
|
+
'ActiveJob resolves jobs by class name, so a service must be assigned ' \
|
|
50
|
+
'to a constant before it can be enqueued.'
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -12,61 +12,54 @@ module Servus
|
|
|
12
12
|
# deduplicates by +#key+ (first wins), and calls +#execute+ on each.
|
|
13
13
|
#
|
|
14
14
|
# An Invocation separates *identity* (service + params) from
|
|
15
|
-
# *
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# regardless of their options.
|
|
15
|
+
# *scheduling* (queue, priority, delay). The +#key+ is derived only
|
|
16
|
+
# from the identity — two invocations that call the same service with
|
|
17
|
+
# the same params are considered duplicates regardless of their options.
|
|
19
18
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# params: { user_id: "abc-123" },
|
|
24
|
-
# options: {}
|
|
25
|
-
# )
|
|
19
|
+
# Invocations are always enqueued, never run inline. A reaction that ran
|
|
20
|
+
# synchronously would put its latency and its failures back into the
|
|
21
|
+
# emitting service, which is what events exist to avoid.
|
|
26
22
|
#
|
|
27
|
-
# @example
|
|
23
|
+
# @example
|
|
28
24
|
# Invocation.new(
|
|
29
25
|
# service: Notifications::Send::Service,
|
|
30
26
|
# params: { user_id: "abc-123" },
|
|
31
|
-
# options: {
|
|
27
|
+
# options: { queue: :mailers, priority: 5 }
|
|
32
28
|
# )
|
|
33
29
|
#
|
|
34
30
|
# @see Servus::Events::Router
|
|
35
31
|
# @see Servus::Events::Bus
|
|
36
32
|
class Invocation
|
|
37
|
-
# @return [Class] the service class to
|
|
33
|
+
# @return [Class] the service class to enqueue (must respond to +.call_async+)
|
|
38
34
|
attr_reader :service
|
|
39
35
|
|
|
40
36
|
# @return [Hash] keyword arguments passed to the service
|
|
41
37
|
attr_reader :params
|
|
42
38
|
|
|
43
|
-
# @return [Hash]
|
|
44
|
-
# +
|
|
39
|
+
# @return [Hash] scheduling options — +queue+, +wait+, +wait_until+,
|
|
40
|
+
# +priority+, +job_options+
|
|
45
41
|
attr_reader :options
|
|
46
42
|
|
|
47
43
|
# @param service [Class] the service class
|
|
48
44
|
# @param params [Hash] keyword arguments for the service
|
|
49
|
-
# @param options [Hash]
|
|
45
|
+
# @param options [Hash] scheduling options
|
|
50
46
|
def initialize(service:, params:, options: {})
|
|
51
47
|
@service = service
|
|
52
48
|
@params = params
|
|
53
49
|
@options = options
|
|
54
50
|
end
|
|
55
51
|
|
|
56
|
-
#
|
|
52
|
+
# Enqueues the invocation via ActiveJob.
|
|
57
53
|
#
|
|
58
|
-
#
|
|
59
|
-
# +
|
|
60
|
-
# options (queue, wait, priority, etc.) are merged into the
|
|
61
|
-
# call_async kwargs.
|
|
54
|
+
# Scheduling options (queue, wait, priority, and so on) are merged into
|
|
55
|
+
# the +call_async+ keyword arguments.
|
|
62
56
|
#
|
|
63
|
-
# @return [
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
end
|
|
57
|
+
# @return [void]
|
|
58
|
+
# @raise [Servus::Events::Errors::AsyncBackendMissingError] if ActiveJob is not loaded
|
|
59
|
+
def enqueue
|
|
60
|
+
raise Errors::AsyncBackendMissingError.for(service) unless service.respond_to?(:call_async)
|
|
61
|
+
|
|
62
|
+
service.call_async(**params, **async_options)
|
|
70
63
|
end
|
|
71
64
|
|
|
72
65
|
# A deterministic deduplication key derived from the service class
|
|
@@ -86,6 +79,7 @@ module Servus
|
|
|
86
79
|
# Extracts scheduling options for +call_async+.
|
|
87
80
|
#
|
|
88
81
|
# @return [Hash]
|
|
82
|
+
# @api private
|
|
89
83
|
def async_options
|
|
90
84
|
options.slice(:queue, :wait, :wait_until, :priority, :job_options).compact
|
|
91
85
|
end
|
|
@@ -81,6 +81,11 @@ module Servus
|
|
|
81
81
|
# The named job class identifies the service — only args are serialized.
|
|
82
82
|
job = job_options.any? ? servus_job_class.set(**job_options) : servus_job_class
|
|
83
83
|
job.perform_later(**args)
|
|
84
|
+
rescue Servus::Support::Errors::ServiceError, Servus::Events::Errors::Error
|
|
85
|
+
# With the :inline and :test adapters perform_later runs the service,
|
|
86
|
+
# so Servus's own errors surface here. Wrapping them as an enqueue
|
|
87
|
+
# failure would blame the wrong layer.
|
|
88
|
+
raise
|
|
84
89
|
rescue StandardError => e
|
|
85
90
|
raise Errors::JobEnqueueError, "Failed to enqueue async job for #{self}: #{e.message}"
|
|
86
91
|
end
|
|
@@ -164,6 +169,8 @@ module Servus
|
|
|
164
169
|
# @return [Class<Servus::Extensions::Async::Job>] the generated job class
|
|
165
170
|
# @api private
|
|
166
171
|
def build_servus_job_class
|
|
172
|
+
raise Servus::Events::Errors::AnonymousServiceError.for(self) if name.nil?
|
|
173
|
+
|
|
167
174
|
klass = Class.new(Servus::Extensions::Async::Job)
|
|
168
175
|
klass.servus_service = self
|
|
169
176
|
|
|
@@ -39,46 +39,6 @@ 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
|
-
|
|
82
42
|
# Renders a service error as a JSON response.
|
|
83
43
|
#
|
|
84
44
|
# Uses error.http_status for the response status code and
|
data/lib/servus/railtie.rb
CHANGED
|
@@ -41,6 +41,9 @@ module Servus
|
|
|
41
41
|
|
|
42
42
|
Servus::Events::Bus.clear if Rails.env.development?
|
|
43
43
|
|
|
44
|
+
# Schemas are cached per class, and reloading replaces those classes.
|
|
45
|
+
Servus::Support::Validator.clear_cache!
|
|
46
|
+
|
|
44
47
|
# Eager load all event classes
|
|
45
48
|
events_path = Rails.root.join(Servus.config.events_dir)
|
|
46
49
|
Dir[File.join(events_path, '**/*_event.rb')].each do |file|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Schema
|
|
5
|
+
# Memoized +$ref+ resolutions, plus the generation counter that invalidates
|
|
6
|
+
# everything derived from them.
|
|
7
|
+
#
|
|
8
|
+
# Entries are keyed by ref string and hold the resolved *target* of that
|
|
9
|
+
# ref, before any sibling properties are merged over it. That is what makes
|
|
10
|
+
# a single entry safe to share across every site that uses the ref: the
|
|
11
|
+
# target depends only on the ref string and the registry contents, and
|
|
12
|
+
# callers apply their own siblings afterwards with +Hash#merge+, which
|
|
13
|
+
# returns a new hash and never mutates the cached one.
|
|
14
|
+
#
|
|
15
|
+
# The generation counter lets consumers that build on compiled schemas —
|
|
16
|
+
# {Servus::Base}, {Servus::Event} — memoize alongside the generation they
|
|
17
|
+
# compiled under and rebuild when it moves, with no dependency tracking.
|
|
18
|
+
#
|
|
19
|
+
# @see Servus::Schema
|
|
20
|
+
# @see Servus::Schema::Compiler
|
|
21
|
+
# @api private
|
|
22
|
+
class Cache
|
|
23
|
+
# Monotonic counter, advanced by {#invalidate!}.
|
|
24
|
+
#
|
|
25
|
+
# @return [Integer]
|
|
26
|
+
attr_reader :generation
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@entries = {}
|
|
30
|
+
@generation = 0
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the memoized resolution of +ref+, computing it on a miss.
|
|
35
|
+
#
|
|
36
|
+
# A raising block leaves no entry behind, so a ref that failed part way
|
|
37
|
+
# through resolution is never cached in a half-built state.
|
|
38
|
+
#
|
|
39
|
+
# @param ref [String] the ref string
|
|
40
|
+
# @yieldreturn [Object] the resolved target, computed on a miss
|
|
41
|
+
# @return [Object] the resolved target
|
|
42
|
+
def resolve(ref)
|
|
43
|
+
cached = @entries[ref]
|
|
44
|
+
return cached unless cached.nil?
|
|
45
|
+
|
|
46
|
+
resolved = yield
|
|
47
|
+
@mutex.synchronize { @entries[ref] = resolved }
|
|
48
|
+
resolved
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Discards every memoized resolution and advances {#generation}.
|
|
52
|
+
#
|
|
53
|
+
# @return [void]
|
|
54
|
+
def invalidate!
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@entries = {}
|
|
57
|
+
@generation += 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Number of memoized resolutions. Used by specs to assert that a repeated
|
|
62
|
+
# ref is expanded once rather than once per occurrence.
|
|
63
|
+
#
|
|
64
|
+
# @return [Integer]
|
|
65
|
+
def size
|
|
66
|
+
@entries.size
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|