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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f382ed3d92dd577c93965ae207c00d28cac6dfddf452cf22fb5dafcd69d56eb
|
|
4
|
+
data.tar.gz: f5a4394a33f5a3061d3c5746fdd058eb457cb29baa2f0adb9cc97c0679f5c158
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ccb1adc1b0bd4b7ff9f8b754571be541bbf7e3b9246746fe73c9e1259ad4b6f6e3a5a4d6c07d3b9f30cb8ddda94dfc2cad7c78fb6dc31fb17440c1d3a19b6be
|
|
7
|
+
data.tar.gz: 0107e7e4d770913a5e86de5c496791e6311c913dc6b2bdad03417b78a2e66be6d50c2fc5d213f87b51162ac4a15d3c6c9dcb9b323267a9db9d0b222099f60245
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Generators
|
|
5
|
+
# Rails generator for creating Servus event classes.
|
|
6
|
+
#
|
|
7
|
+
# Generates an event class and spec file. The event name is inferred
|
|
8
|
+
# from the class name — no explicit +event_name+ call needed.
|
|
9
|
+
#
|
|
10
|
+
# @example Generate an event
|
|
11
|
+
# rails g servus:event referral_verified
|
|
12
|
+
#
|
|
13
|
+
# @example Generated files
|
|
14
|
+
# app/events/referral_verified.rb
|
|
15
|
+
# spec/app/events/referral_verified_spec.rb
|
|
16
|
+
#
|
|
17
|
+
# @see https://guides.rubyonrails.org/generators.html
|
|
18
|
+
class EventGenerator < Rails::Generators::NamedBase
|
|
19
|
+
source_root File.expand_path('templates', __dir__)
|
|
20
|
+
|
|
21
|
+
class_option :no_docs, type: :boolean,
|
|
22
|
+
default: false,
|
|
23
|
+
desc: 'Skip documentation comments in generated files'
|
|
24
|
+
|
|
25
|
+
# Creates the event class and spec files.
|
|
26
|
+
#
|
|
27
|
+
# @return [void]
|
|
28
|
+
def create_event_file
|
|
29
|
+
template 'event.rb.erb', event_path
|
|
30
|
+
template 'event_spec.rb.erb', event_spec_path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# @return [String] event file path
|
|
36
|
+
# @api private
|
|
37
|
+
def event_path
|
|
38
|
+
File.join(Servus.config.events_dir, "#{file_name}_event.rb")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String] spec file path
|
|
42
|
+
# @api private
|
|
43
|
+
def event_spec_path
|
|
44
|
+
File.join(Servus.config.tests_dir, Servus.config.events_dir, "#{file_name}_event_spec.rb")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String] event class name (e.g. "ReferralVerifiedEvent")
|
|
48
|
+
# @api private
|
|
49
|
+
def event_class_name
|
|
50
|
+
"#{class_name}Event"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<%- unless options[:no_docs] -%>
|
|
4
|
+
# Defines the :<%= file_name %> event.
|
|
5
|
+
#
|
|
6
|
+
# Event classes declare the contract (schema) for an event and optionally
|
|
7
|
+
# wire up service invocations that run when the event fires. The event
|
|
8
|
+
# name is inferred from the class name.
|
|
9
|
+
#
|
|
10
|
+
# @example Emit this event from anywhere
|
|
11
|
+
# <%= event_class_name %>.emit({ user_id: 123 })
|
|
12
|
+
#
|
|
13
|
+
# @example Invoke a service when this event fires
|
|
14
|
+
# invoke SendEmail::Service, async: true do |payload|
|
|
15
|
+
# { user_id: payload[:user_id] }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Pass full payload through (no mapper block)
|
|
19
|
+
# invoke AuditLogger::Service, async: true
|
|
20
|
+
#
|
|
21
|
+
# @example Conditional invocation
|
|
22
|
+
# invoke GrantRewards::Service, if: ->(payload) { payload[:premium] } do |payload|
|
|
23
|
+
# { user_id: payload[:user_id] }
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# Available options for `invoke`:
|
|
27
|
+
# - async: true - Invoke service asynchronously via ActiveJob
|
|
28
|
+
# - queue: :queue_name - Specify ActiveJob queue (requires async: true)
|
|
29
|
+
# - if: ->(payload) {} - Condition that must be true to invoke
|
|
30
|
+
# - unless: ->(payload) {} - Condition that must be false to invoke
|
|
31
|
+
#
|
|
32
|
+
# @see Servus::Event
|
|
33
|
+
# @see Servus::Events::Bus
|
|
34
|
+
<%- end -%>
|
|
35
|
+
class <%= event_class_name %> < Servus::Event
|
|
36
|
+
schema payload: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
description: '<%= event_class_name %> event payload',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# invoke YourService, async: true do |payload|
|
|
42
|
+
# { example_arg: payload[:example_field] }
|
|
43
|
+
# end
|
|
44
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe <%= event_class_name %> do
|
|
6
|
+
let(:payload) do
|
|
7
|
+
{
|
|
8
|
+
# TODO: Add sample payload fields
|
|
9
|
+
}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
<%- unless options[:no_docs] -%>
|
|
13
|
+
# TODO: Add tests for service invocations
|
|
14
|
+
# it 'invokes YourService with mapped arguments' do
|
|
15
|
+
# expect { described_class.emit(payload) }
|
|
16
|
+
# .to emit_event(:<%= file_name %>)
|
|
17
|
+
# .with(hash_including(expected_field: 'value'))
|
|
18
|
+
# end
|
|
19
|
+
<%- end -%>
|
|
20
|
+
end
|
|
@@ -44,7 +44,7 @@ module Servus
|
|
|
44
44
|
# @return [String] spec file path
|
|
45
45
|
# @api private
|
|
46
46
|
def guard_spec_path
|
|
47
|
-
File.join(
|
|
47
|
+
File.join(Servus.config.tests_dir, Servus.config.guards_dir, "#{file_name}_guard_spec.rb")
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# Returns the guard class name.
|
|
@@ -34,14 +34,16 @@ class <%= guard_class_name %> < Servus::Guard
|
|
|
34
34
|
|
|
35
35
|
# Tests whether the <%= file_name.humanize.downcase %> condition is met.
|
|
36
36
|
#
|
|
37
|
-
#
|
|
37
|
+
# Arguments passed to `enforce_*!` / `check_*?` are stored as `@kwargs`
|
|
38
|
+
# and exposed via `method_missing`, so read them directly off the instance.
|
|
39
|
+
#
|
|
38
40
|
# @return [Boolean] true if validation passes
|
|
39
|
-
def test
|
|
41
|
+
def test
|
|
40
42
|
# TODO: Implement validation logic
|
|
41
43
|
# Return true if the condition is met, false otherwise
|
|
42
44
|
#
|
|
43
45
|
# Example:
|
|
44
|
-
# def test
|
|
46
|
+
# def test
|
|
45
47
|
# account.balance >= amount
|
|
46
48
|
# end
|
|
47
49
|
true
|
|
@@ -57,7 +57,7 @@ module Servus
|
|
|
57
57
|
# @return [String] spec file path
|
|
58
58
|
# @api private
|
|
59
59
|
def service_path_spec
|
|
60
|
-
"
|
|
60
|
+
"#{Servus.config.tests_dir}/services/#{file_path}/service_spec.rb"
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Returns the path for the result schema file.
|
data/lib/servus/base.rb
CHANGED
|
@@ -48,6 +48,7 @@ module Servus
|
|
|
48
48
|
class Base
|
|
49
49
|
include Servus::Support::Errors
|
|
50
50
|
include Servus::Support::Rescuer
|
|
51
|
+
include Servus::Support::Lockdown
|
|
51
52
|
include Servus::Events::Emitter
|
|
52
53
|
include Servus::Guards
|
|
53
54
|
|
|
@@ -157,6 +158,46 @@ module Servus
|
|
|
157
158
|
raise type, message
|
|
158
159
|
end
|
|
159
160
|
|
|
161
|
+
# Invokes another service from within this service's {#call} and returns its
|
|
162
|
+
# data on success. On failure, halts the outer service with the sub-service's
|
|
163
|
+
# failure Response — the outer service's caller receives that Response
|
|
164
|
+
# unchanged (same error object, message, code, http_status).
|
|
165
|
+
#
|
|
166
|
+
# Sugar over:
|
|
167
|
+
#
|
|
168
|
+
# result = SubService.call(**params)
|
|
169
|
+
# return result unless result.success?
|
|
170
|
+
# data = result.data
|
|
171
|
+
#
|
|
172
|
+
# Only call from within a service's `#call` (or helpers reachable from
|
|
173
|
+
# it); the throw is caught by {Servus::Base.call}.
|
|
174
|
+
#
|
|
175
|
+
# @example Composing services
|
|
176
|
+
# class SendDigitalCash::Service < Servus::Base
|
|
177
|
+
# def call
|
|
178
|
+
# data1 = call!(Accounts::Lookup::Service, id: account_id)
|
|
179
|
+
# data2 = call!(Ledger::RecordTransfer::Service, account:, amount:)
|
|
180
|
+
# success(ref: data2.ref)
|
|
181
|
+
# end
|
|
182
|
+
# end
|
|
183
|
+
#
|
|
184
|
+
# For invoking a service from *outside* a service context (controllers,
|
|
185
|
+
# rake tasks, jobs, consoles), see
|
|
186
|
+
# {Servus::Helpers::ControllerHelpers#run_service!}.
|
|
187
|
+
#
|
|
188
|
+
# @param service_class [Class<Servus::Base>] the sub-service to invoke
|
|
189
|
+
# @param params [Hash] keyword arguments to pass to the sub-service
|
|
190
|
+
# @return [Servus::Support::DataObject, Object] the sub-service's data on success
|
|
191
|
+
# @throw [:guard_failure, Servus::Support::Response] the failure Response, otherwise
|
|
192
|
+
#
|
|
193
|
+
# @see Servus::Helpers::ControllerHelpers#run_service!
|
|
194
|
+
def call!(service_class, **params)
|
|
195
|
+
result = service_class.call(**params)
|
|
196
|
+
return result.data if result.success?
|
|
197
|
+
|
|
198
|
+
throw(:guard_failure, result)
|
|
199
|
+
end
|
|
200
|
+
|
|
160
201
|
class << self
|
|
161
202
|
# Executes the service with automatic validation, logging, and benchmarking.
|
|
162
203
|
#
|
|
@@ -196,11 +237,13 @@ module Servus
|
|
|
196
237
|
|
|
197
238
|
# Wrap execution in catch block to handle guard failures
|
|
198
239
|
result = catch(:guard_failure) do
|
|
199
|
-
benchmark(**args) { instance.call }
|
|
240
|
+
benchmark(**args) { instance.send(:call) }
|
|
200
241
|
end
|
|
201
242
|
|
|
202
|
-
|
|
203
|
-
|
|
243
|
+
if result.is_a?(Servus::Support::Errors::GuardError)
|
|
244
|
+
Logger.log_guard_failure(self, result)
|
|
245
|
+
result = Response.new(false, nil, result)
|
|
246
|
+
end
|
|
204
247
|
|
|
205
248
|
after_call(result, instance)
|
|
206
249
|
|
data/lib/servus/config.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Servus
|
|
|
22
22
|
# @return [String] the schemas directory path
|
|
23
23
|
attr_accessor :schemas_dir
|
|
24
24
|
|
|
25
|
-
# The directory where
|
|
25
|
+
# The directory where Event classes are located.
|
|
26
26
|
#
|
|
27
27
|
# Defaults to `Rails.root/app/events` in Rails applications.
|
|
28
28
|
#
|
|
@@ -36,14 +36,6 @@ module Servus
|
|
|
36
36
|
# @return [String] the services directory path
|
|
37
37
|
attr_accessor :services_dir
|
|
38
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
|
|
46
|
-
|
|
47
39
|
# The directory where guard classes are located.
|
|
48
40
|
#
|
|
49
41
|
# Defaults to `Rails.root/app/guards` in Rails applications.
|
|
@@ -51,22 +43,103 @@ module Servus
|
|
|
51
43
|
# @return [String] the guards directory path
|
|
52
44
|
attr_accessor :guards_dir
|
|
53
45
|
|
|
46
|
+
# The directory where generated spec/test files are placed.
|
|
47
|
+
#
|
|
48
|
+
# Defaults to `"spec"`. Projects using Minitest or a custom test layout
|
|
49
|
+
# can override this (e.g., `"test"`) so generators write files into the
|
|
50
|
+
# correct location.
|
|
51
|
+
#
|
|
52
|
+
# @return [String] the tests directory path
|
|
53
|
+
attr_accessor :tests_dir
|
|
54
|
+
|
|
54
55
|
# Whether to include the default built-in guards (EnsurePresent, EnsurePositive).
|
|
55
56
|
#
|
|
56
57
|
# @return [Boolean] true to include default guards, false to exclude them
|
|
57
58
|
attr_accessor :include_default_guards
|
|
58
59
|
|
|
60
|
+
# Whether to require all services to define an arguments schema.
|
|
61
|
+
#
|
|
62
|
+
# When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
|
|
63
|
+
# a service is called without an arguments schema defined.
|
|
64
|
+
#
|
|
65
|
+
# @return [Boolean] true to require arguments schemas, false to allow schema-less services
|
|
66
|
+
attr_accessor :require_service_arguments_schema
|
|
67
|
+
|
|
68
|
+
# Whether to require all services to define a result schema.
|
|
69
|
+
#
|
|
70
|
+
# When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
|
|
71
|
+
# a service returns a successful response without a result schema defined.
|
|
72
|
+
# Failure schemas remain optional regardless of this setting.
|
|
73
|
+
#
|
|
74
|
+
# @return [Boolean] true to require result schemas, false to allow schema-less services
|
|
75
|
+
attr_accessor :require_service_result_schema
|
|
76
|
+
|
|
77
|
+
# Whether to require all event classes to define a payload schema.
|
|
78
|
+
#
|
|
79
|
+
# When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
|
|
80
|
+
# an event validates a payload without a payload schema defined.
|
|
81
|
+
#
|
|
82
|
+
# @return [Boolean] true to require payload schemas, false to allow schema-less events
|
|
83
|
+
attr_accessor :require_event_payload_schema
|
|
84
|
+
|
|
85
|
+
# The ordered list of routers that resolve invocations for events.
|
|
86
|
+
#
|
|
87
|
+
# The Bus iterates routers in order, collects invocations, deduplicates
|
|
88
|
+
# by key (first wins), and executes. Defaults to +[ClassRouter.new]+
|
|
89
|
+
# which reads +invoke+ declarations from Event classes.
|
|
90
|
+
#
|
|
91
|
+
# @return [Array<Servus::Events::Router>]
|
|
92
|
+
attr_writer :routers
|
|
93
|
+
|
|
94
|
+
# @return [Array<Servus::Events::Router>]
|
|
95
|
+
def routers
|
|
96
|
+
@routers || [Servus::Events::ClassRouter.new]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Whether external instantiation of services is blocked and instance
|
|
100
|
+
# `#call` methods are automatically privatized.
|
|
101
|
+
#
|
|
102
|
+
# When enabled (default), callers must invoke services via the class
|
|
103
|
+
# method {Servus::Base.call}, which runs argument validation, logging,
|
|
104
|
+
# benchmarking, guards, result validation, and event emission. Calling
|
|
105
|
+
# `MyService.new` or `instance.call` directly raises `NoMethodError`.
|
|
106
|
+
#
|
|
107
|
+
# Disable this if you have existing code that instantiates services
|
|
108
|
+
# directly or otherwise prefer to opt out of the enforcement.
|
|
109
|
+
#
|
|
110
|
+
# @return [Boolean] true to enforce lockdown (default), false to allow
|
|
111
|
+
# direct instantiation and public instance `#call`
|
|
112
|
+
# @see Servus::Support::Lockdown
|
|
113
|
+
attr_reader :lockdown_enabled
|
|
114
|
+
|
|
115
|
+
# Sets whether lockdown is enforced, immediately re-applying the
|
|
116
|
+
# resulting `.new` visibility to {Servus::Base}.
|
|
117
|
+
#
|
|
118
|
+
# @param value [Boolean] the new lockdown setting
|
|
119
|
+
# @return [Boolean] the new value
|
|
120
|
+
def lockdown_enabled=(value)
|
|
121
|
+
@lockdown_enabled = value
|
|
122
|
+
Servus::Base.apply_lockdown!
|
|
123
|
+
end
|
|
124
|
+
|
|
59
125
|
# Initializes a new configuration with default values.
|
|
60
126
|
#
|
|
61
127
|
# @api private
|
|
62
128
|
def initialize
|
|
129
|
+
set_default_directories
|
|
130
|
+
@include_default_guards = true
|
|
131
|
+
@lockdown_enabled = true
|
|
132
|
+
@require_service_arguments_schema = false
|
|
133
|
+
@require_service_result_schema = false
|
|
134
|
+
@require_event_payload_schema = false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def set_default_directories
|
|
63
138
|
@guards_dir = 'app/guards'
|
|
64
139
|
@events_dir = 'app/events'
|
|
65
140
|
@schemas_dir = 'app/schemas'
|
|
66
141
|
@services_dir = 'app/services'
|
|
67
|
-
|
|
68
|
-
@strict_event_validation = true
|
|
69
|
-
@include_default_guards = true
|
|
142
|
+
@tests_dir = 'spec'
|
|
70
143
|
end
|
|
71
144
|
|
|
72
145
|
# Returns the full path to a service's schema file.
|
data/lib/servus/event.rb
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
# Base class for event definitions.
|
|
5
|
+
#
|
|
6
|
+
# Event classes live in app/events/ and serve three purposes:
|
|
7
|
+
#
|
|
8
|
+
# 1. *Contract* — declares the event exists and defines its name
|
|
9
|
+
# 2. *Validator* — schema enforcement on any emission
|
|
10
|
+
# 3. *Declarative routing* — optional +invoke+ declarations
|
|
11
|
+
#
|
|
12
|
+
# The event name can be set explicitly via +event_name+ or inferred
|
|
13
|
+
# from the class name (e.g. +OrderPlaced+ becomes +:order_placed+).
|
|
14
|
+
# Call +ensure_registered!+ to trigger inference for classes that
|
|
15
|
+
# don't declare an explicit name.
|
|
16
|
+
#
|
|
17
|
+
# @example Event with explicit name and invoke declarations
|
|
18
|
+
# class UserCreated < Servus::Event
|
|
19
|
+
# event_name :user_created
|
|
20
|
+
#
|
|
21
|
+
# schema payload: { type: 'object', required: ['user_id'] }
|
|
22
|
+
#
|
|
23
|
+
# invoke SendWelcomeEmail::Service, async: true do |payload|
|
|
24
|
+
# { user_id: payload[:user_id] }
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example Event with inferred name (no invoke — schema-only contract)
|
|
29
|
+
# class OrderPlaced < Servus::Event
|
|
30
|
+
# schema payload: { type: 'object', required: ['order_id'] }
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @example Event that passes full payload through (no mapper block)
|
|
34
|
+
# class AuditLogCreated < Servus::Event
|
|
35
|
+
# event_name :audit_log_created
|
|
36
|
+
#
|
|
37
|
+
# invoke AuditLogger::Service, async: true
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# @see Servus::Events::Bus
|
|
41
|
+
# @see Servus::Events::Router
|
|
42
|
+
# @see Servus::Base
|
|
43
|
+
class Event
|
|
44
|
+
class << self
|
|
45
|
+
# Declares or returns the event name.
|
|
46
|
+
#
|
|
47
|
+
# When called with an argument, sets the event name and registers
|
|
48
|
+
# with the Bus. When called without arguments, returns the current
|
|
49
|
+
# event name.
|
|
50
|
+
#
|
|
51
|
+
# If never called explicitly, use +ensure_registered!+ to infer
|
|
52
|
+
# the name from the class name.
|
|
53
|
+
#
|
|
54
|
+
# @overload event_name(name)
|
|
55
|
+
# @param name [Symbol] the event name to register
|
|
56
|
+
# @return [void]
|
|
57
|
+
# @raise [RuntimeError] if called twice with different names
|
|
58
|
+
#
|
|
59
|
+
# @overload event_name
|
|
60
|
+
# @return [Symbol, nil] the event name or nil if not configured
|
|
61
|
+
#
|
|
62
|
+
# @example Explicit name
|
|
63
|
+
# class UserCreated < Servus::Event
|
|
64
|
+
# event_name :user_created
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# @example Inferred name (via ensure_registered!)
|
|
68
|
+
# class OrderPlaced < Servus::Event; end
|
|
69
|
+
# OrderPlaced.ensure_registered!
|
|
70
|
+
# OrderPlaced.event_name # => :order_placed
|
|
71
|
+
def event_name(name = nil)
|
|
72
|
+
return @event_name if name.nil?
|
|
73
|
+
|
|
74
|
+
raise "Event already subscribed to :#{@event_name}. Cannot subscribe to :#{name}" if @event_name
|
|
75
|
+
|
|
76
|
+
@event_name = name
|
|
77
|
+
Servus::Events::Bus.register_event(name, self)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Infers and registers the event name from the class name if not
|
|
81
|
+
# already set explicitly. Safe to call multiple times — does
|
|
82
|
+
# nothing if already registered. Skips anonymous classes.
|
|
83
|
+
#
|
|
84
|
+
# @return [void]
|
|
85
|
+
def ensure_registered!
|
|
86
|
+
return if @event_name
|
|
87
|
+
return if name.nil?
|
|
88
|
+
|
|
89
|
+
event_name(name.demodulize.underscore.to_sym)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Declares a service invocation in response to the event.
|
|
93
|
+
#
|
|
94
|
+
# Multiple invocations can be declared for a single event. Each invocation
|
|
95
|
+
# requires a block that maps the event payload to the service's arguments.
|
|
96
|
+
#
|
|
97
|
+
# @param service_class [Class] the service class to invoke (must inherit from Servus::Base)
|
|
98
|
+
# @param options [Hash] invocation options
|
|
99
|
+
# @option options [Boolean] :async invoke the service asynchronously via call_async
|
|
100
|
+
# @option options [Symbol] :queue the queue name for async jobs
|
|
101
|
+
# @option options [Proc] :if condition that must return true for invocation
|
|
102
|
+
# @option options [Proc] :unless condition that must return false for invocation
|
|
103
|
+
# @yield [payload] block that maps event payload to service arguments
|
|
104
|
+
# @yieldparam payload [Hash] the event payload
|
|
105
|
+
# @yieldreturn [Hash] keyword arguments for the service's initialize method
|
|
106
|
+
# @return [void]
|
|
107
|
+
#
|
|
108
|
+
# @example Basic invocation
|
|
109
|
+
# invoke SendEmail::Service do |payload|
|
|
110
|
+
# { user_id: payload[:user_id], email: payload[:email] }
|
|
111
|
+
# end
|
|
112
|
+
#
|
|
113
|
+
# @example Async invocation with queue
|
|
114
|
+
# invoke SendEmail::Service, async: true, queue: :mailers do |payload|
|
|
115
|
+
# { user_id: payload[:user_id] }
|
|
116
|
+
# end
|
|
117
|
+
#
|
|
118
|
+
# @example Conditional invocation
|
|
119
|
+
# invoke GrantRewards::Service, if: ->(p) { p[:premium] } do |payload|
|
|
120
|
+
# { user_id: payload[:user_id] }
|
|
121
|
+
# end
|
|
122
|
+
def invoke(service_class, options = {}, &block)
|
|
123
|
+
@invocations ||= []
|
|
124
|
+
@invocations << {
|
|
125
|
+
service_class: service_class,
|
|
126
|
+
options: options,
|
|
127
|
+
mapper: block || ->(payload) { payload }
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Returns all service invocations declared for this event.
|
|
132
|
+
#
|
|
133
|
+
# @return [Array<Hash>] array of invocation configurations
|
|
134
|
+
def invocations
|
|
135
|
+
@invocations || []
|
|
136
|
+
end
|
|
137
|
+
|
|
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
|
+
# Emits this event via the Bus.
|
|
167
|
+
#
|
|
168
|
+
# Provides a type-safe, discoverable way to emit events from anywhere in
|
|
169
|
+
# the application (controllers, jobs, rake tasks) without creating a service.
|
|
170
|
+
#
|
|
171
|
+
# @param payload [Hash] the event payload
|
|
172
|
+
# @return [void]
|
|
173
|
+
# @raise [RuntimeError] if no event name configured
|
|
174
|
+
#
|
|
175
|
+
# @example Emit from controller
|
|
176
|
+
# class UsersController
|
|
177
|
+
# def create
|
|
178
|
+
# user = User.create!(params)
|
|
179
|
+
# UserCreated.emit({ user_id: user.id, email: user.email })
|
|
180
|
+
# redirect_to user
|
|
181
|
+
# end
|
|
182
|
+
# end
|
|
183
|
+
#
|
|
184
|
+
# @example Emit from background job
|
|
185
|
+
# class ProcessDataJob
|
|
186
|
+
# def perform(data_id)
|
|
187
|
+
# result = process_data(data_id)
|
|
188
|
+
# DataProcessed.emit({ data_id: data_id, status: result })
|
|
189
|
+
# end
|
|
190
|
+
# end
|
|
191
|
+
def emit(payload)
|
|
192
|
+
raise 'No event configured. Call event_name :name first.' unless @event_name
|
|
193
|
+
|
|
194
|
+
Servus::Support::Validator.validate_event_payload!(self, payload)
|
|
195
|
+
|
|
196
|
+
Servus::Events::Bus.emit(@event_name, payload)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Returns Invocation objects for the given payload, with conditions
|
|
200
|
+
# already evaluated. This is what routers call to resolve actions.
|
|
201
|
+
#
|
|
202
|
+
# @param payload [Hash] the event payload
|
|
203
|
+
# @return [Array<Servus::Events::Invocation>] invocations that passed conditions
|
|
204
|
+
def invocations_for(payload)
|
|
205
|
+
invocations.filter_map do |inv|
|
|
206
|
+
next unless should_invoke?(payload, inv[:options])
|
|
207
|
+
|
|
208
|
+
Servus::Events::Invocation.new(
|
|
209
|
+
service: inv[:service_class],
|
|
210
|
+
params: inv[:mapper].call(payload),
|
|
211
|
+
options: inv[:options].except(:if, :unless)
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Handles an event by resolving and executing all invocations.
|
|
217
|
+
#
|
|
218
|
+
# @param payload [Hash] the event payload
|
|
219
|
+
# @return [Array] results from all invoked services
|
|
220
|
+
def handle(payload)
|
|
221
|
+
invocations_for(payload).map(&:execute)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
# @api private
|
|
227
|
+
def should_invoke?(payload, options)
|
|
228
|
+
return false if options[:if] && !options[:if].call(payload)
|
|
229
|
+
return false if options[:unless]&.call(payload)
|
|
230
|
+
|
|
231
|
+
true
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|