servus 0.2.1 → 0.4.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_handler/event_handler_generator.rb +1 -1
- 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 +67 -9
- data/lib/servus/config.rb +71 -3
- data/lib/servus/events/bus.rb +29 -0
- data/lib/servus/events/emitter.rb +15 -0
- data/lib/servus/extensions/lazily/call.rb +82 -0
- data/lib/servus/extensions/lazily/errors.rb +37 -0
- data/lib/servus/extensions/lazily/ext.rb +23 -0
- data/lib/servus/extensions/lazily/resolver.rb +32 -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 +7 -1
- data/lib/servus/support/data_object.rb +80 -0
- data/lib/servus/support/errors.rb +16 -0
- data/lib/servus/support/lockdown.rb +94 -0
- data/lib/servus/support/logger.rb +16 -0
- data/lib/servus/support/response.rb +12 -1
- data/lib/servus/support/validator.rb +79 -34
- data/lib/servus/testing/example_builders.rb +74 -0
- data/lib/servus/testing/matchers.rb +99 -0
- data/lib/servus/version.rb +1 -1
- data/lib/servus.rb +2 -0
- metadata +16 -114
- 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 -122
- 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 -77
- data/docs/core/2_architecture.md +0 -120
- data/docs/core/3_service_objects.md +0 -121
- data/docs/features/1_schema_validation.md +0 -119
- data/docs/features/2_error_handling.md +0 -121
- 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/guards_naming_convention.md +0 -540
- data/docs/guides/1_common_patterns.md +0 -90
- data/docs/guides/2_migration_guide.md +0 -175
- data/docs/integration/1_configuration.md +0 -154
- data/docs/integration/2_testing.md +0 -287
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e468137fc40c9d2f18214dea55b9c3fc6211e5479e73f5b0917ed168d116a2f
|
|
4
|
+
data.tar.gz: 5390b065cb3478d837cbd90047a7f8facaaf33273b0e2aa4d459167343354d8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 20e5b65a0cd82207a0c494b76e21d0562ce41ca27394b418e7d6921b841c4f47d4f8384f157146195eea713f1af3d28d09b81c210fd7f8a5041b13f8464d66a2
|
|
7
|
+
data.tar.gz: 52f7ca40905dc890fa6850cf87b8394471a94607749457aaeb4784c4524315fd769e40daa4322aae12b5bd84eb9282524a90836bab8a31ab9abfe01d021211a7
|
|
@@ -44,7 +44,7 @@ module Servus
|
|
|
44
44
|
# @return [String] spec file path
|
|
45
45
|
# @api private
|
|
46
46
|
def handler_spec_path
|
|
47
|
-
File.join(
|
|
47
|
+
File.join(Servus.config.tests_dir, Servus.config.events_dir, "#{file_name}_handler_spec.rb")
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# Returns the handler class name.
|
|
@@ -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
|
|
|
@@ -88,6 +89,8 @@ module Servus
|
|
|
88
89
|
# The failure is logged automatically and returns a response containing the error.
|
|
89
90
|
#
|
|
90
91
|
# @param message [String, nil] custom error message (uses error type's default if nil)
|
|
92
|
+
# @param data [Object, nil] optional structured data to attach to the failure response.
|
|
93
|
+
# When a +failure+ schema is defined, this data will be validated against it.
|
|
91
94
|
# @param type [Class] error class to instantiate (must inherit from ServiceError)
|
|
92
95
|
# @return [Servus::Support::Response] response with success: false and the error
|
|
93
96
|
#
|
|
@@ -109,12 +112,17 @@ module Servus
|
|
|
109
112
|
# # Uses "Not found" as the message
|
|
110
113
|
# end
|
|
111
114
|
#
|
|
115
|
+
# @example Attaching structured data to a failure
|
|
116
|
+
# def call
|
|
117
|
+
# return failure("Approval required", data: { requires_human_approval: true })
|
|
118
|
+
# end
|
|
119
|
+
#
|
|
112
120
|
# @see #success
|
|
113
121
|
# @see #error!
|
|
114
122
|
# @see Servus::Support::Errors
|
|
115
|
-
def failure(message = nil, type: Servus::Support::Errors::ServiceError)
|
|
123
|
+
def failure(message = nil, data: nil, type: Servus::Support::Errors::ServiceError)
|
|
116
124
|
error = type.new(message)
|
|
117
|
-
Response.new(false,
|
|
125
|
+
Response.new(false, data, error)
|
|
118
126
|
end
|
|
119
127
|
|
|
120
128
|
# Logs an error and raises an exception, halting service execution.
|
|
@@ -150,6 +158,46 @@ module Servus
|
|
|
150
158
|
raise type, message
|
|
151
159
|
end
|
|
152
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
|
+
|
|
153
201
|
class << self
|
|
154
202
|
# Executes the service with automatic validation, logging, and benchmarking.
|
|
155
203
|
#
|
|
@@ -189,11 +237,13 @@ module Servus
|
|
|
189
237
|
|
|
190
238
|
# Wrap execution in catch block to handle guard failures
|
|
191
239
|
result = catch(:guard_failure) do
|
|
192
|
-
benchmark(**args) { instance.call }
|
|
240
|
+
benchmark(**args) { instance.send(:call) }
|
|
193
241
|
end
|
|
194
242
|
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
197
247
|
|
|
198
248
|
after_call(result, instance)
|
|
199
249
|
|
|
@@ -207,15 +257,16 @@ module Servus
|
|
|
207
257
|
end
|
|
208
258
|
# rubocop:enable Metrics/MethodLength
|
|
209
259
|
|
|
210
|
-
# Defines schema validation rules for the service's arguments and/or
|
|
260
|
+
# Defines schema validation rules for the service's arguments, result, and/or failure data.
|
|
211
261
|
#
|
|
212
262
|
# This method provides a clean DSL for specifying JSON schemas that will be used
|
|
213
263
|
# to validate service inputs and outputs. Schemas defined via this method take
|
|
214
|
-
# precedence over ARGUMENTS_SCHEMA and
|
|
215
|
-
# version will deprecate those constants in favor of this DSL.
|
|
264
|
+
# precedence over ARGUMENTS_SCHEMA, RESULT_SCHEMA, and FAILURE_SCHEMA constants.
|
|
265
|
+
# The next major version will deprecate those constants in favor of this DSL.
|
|
216
266
|
#
|
|
217
267
|
# @param arguments [Hash, nil] JSON schema for validating service arguments
|
|
218
268
|
# @param result [Hash, nil] JSON schema for validating service result data
|
|
269
|
+
# @param failure [Hash, nil] JSON schema for validating failure response data
|
|
219
270
|
# @return [void]
|
|
220
271
|
#
|
|
221
272
|
# @example Defining both arguments and result schemas
|
|
@@ -245,9 +296,10 @@ module Servus
|
|
|
245
296
|
# end
|
|
246
297
|
#
|
|
247
298
|
# @see Servus::Support::Validator
|
|
248
|
-
def schema(arguments: nil, result: nil)
|
|
299
|
+
def schema(arguments: nil, result: nil, failure: nil)
|
|
249
300
|
@arguments_schema = arguments.with_indifferent_access if arguments
|
|
250
301
|
@result_schema = result.with_indifferent_access if result
|
|
302
|
+
@failure_schema = failure.with_indifferent_access if failure
|
|
251
303
|
end
|
|
252
304
|
|
|
253
305
|
# Returns the arguments schema defined via the schema DSL method.
|
|
@@ -262,6 +314,12 @@ module Servus
|
|
|
262
314
|
# @api private
|
|
263
315
|
attr_reader :result_schema
|
|
264
316
|
|
|
317
|
+
# Returns the failure schema defined via the schema DSL method.
|
|
318
|
+
#
|
|
319
|
+
# @return [Hash, nil] the failure schema or nil if not defined
|
|
320
|
+
# @api private
|
|
321
|
+
attr_reader :failure_schema
|
|
322
|
+
|
|
265
323
|
# Executes pre-call hooks including logging and argument validation.
|
|
266
324
|
#
|
|
267
325
|
# This method is automatically called before service execution and handles:
|
data/lib/servus/config.rb
CHANGED
|
@@ -51,22 +51,90 @@ module Servus
|
|
|
51
51
|
# @return [String] the guards directory path
|
|
52
52
|
attr_accessor :guards_dir
|
|
53
53
|
|
|
54
|
+
# The directory where generated spec/test files are placed.
|
|
55
|
+
#
|
|
56
|
+
# Defaults to `"spec"`. Projects using Minitest or a custom test layout
|
|
57
|
+
# can override this (e.g., `"test"`) so generators write files into the
|
|
58
|
+
# correct location.
|
|
59
|
+
#
|
|
60
|
+
# @return [String] the tests directory path
|
|
61
|
+
attr_accessor :tests_dir
|
|
62
|
+
|
|
54
63
|
# Whether to include the default built-in guards (EnsurePresent, EnsurePositive).
|
|
55
64
|
#
|
|
56
65
|
# @return [Boolean] true to include default guards, false to exclude them
|
|
57
66
|
attr_accessor :include_default_guards
|
|
58
67
|
|
|
68
|
+
# Whether to require all services to define an arguments schema.
|
|
69
|
+
#
|
|
70
|
+
# When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
|
|
71
|
+
# a service is called without an arguments schema defined.
|
|
72
|
+
#
|
|
73
|
+
# @return [Boolean] true to require arguments schemas, false to allow schema-less services
|
|
74
|
+
attr_accessor :require_service_arguments_schema
|
|
75
|
+
|
|
76
|
+
# Whether to require all services to define a result schema.
|
|
77
|
+
#
|
|
78
|
+
# When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
|
|
79
|
+
# a service returns a successful response without a result schema defined.
|
|
80
|
+
# Failure schemas remain optional regardless of this setting.
|
|
81
|
+
#
|
|
82
|
+
# @return [Boolean] true to require result schemas, false to allow schema-less services
|
|
83
|
+
attr_accessor :require_service_result_schema
|
|
84
|
+
|
|
85
|
+
# Whether to require all event handlers to define a payload schema.
|
|
86
|
+
#
|
|
87
|
+
# When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
|
|
88
|
+
# an event handler validates a payload without a payload schema defined.
|
|
89
|
+
#
|
|
90
|
+
# @return [Boolean] true to require payload schemas, false to allow schema-less handlers
|
|
91
|
+
attr_accessor :require_event_payload_schema
|
|
92
|
+
|
|
93
|
+
# Whether external instantiation of services is blocked and instance
|
|
94
|
+
# `#call` methods are automatically privatized.
|
|
95
|
+
#
|
|
96
|
+
# When enabled (default), callers must invoke services via the class
|
|
97
|
+
# method {Servus::Base.call}, which runs argument validation, logging,
|
|
98
|
+
# benchmarking, guards, result validation, and event emission. Calling
|
|
99
|
+
# `MyService.new` or `instance.call` directly raises `NoMethodError`.
|
|
100
|
+
#
|
|
101
|
+
# Disable this if you have existing code that instantiates services
|
|
102
|
+
# directly or otherwise prefer to opt out of the enforcement.
|
|
103
|
+
#
|
|
104
|
+
# @return [Boolean] true to enforce lockdown (default), false to allow
|
|
105
|
+
# direct instantiation and public instance `#call`
|
|
106
|
+
# @see Servus::Support::Lockdown
|
|
107
|
+
attr_reader :lockdown_enabled
|
|
108
|
+
|
|
109
|
+
# Sets whether lockdown is enforced, immediately re-applying the
|
|
110
|
+
# resulting `.new` visibility to {Servus::Base}.
|
|
111
|
+
#
|
|
112
|
+
# @param value [Boolean] the new lockdown setting
|
|
113
|
+
# @return [Boolean] the new value
|
|
114
|
+
def lockdown_enabled=(value)
|
|
115
|
+
@lockdown_enabled = value
|
|
116
|
+
Servus::Base.apply_lockdown!
|
|
117
|
+
end
|
|
118
|
+
|
|
59
119
|
# Initializes a new configuration with default values.
|
|
60
120
|
#
|
|
61
121
|
# @api private
|
|
62
122
|
def initialize
|
|
123
|
+
set_default_directories
|
|
124
|
+
@strict_event_validation = true
|
|
125
|
+
@include_default_guards = true
|
|
126
|
+
@lockdown_enabled = true
|
|
127
|
+
@require_service_arguments_schema = false
|
|
128
|
+
@require_service_result_schema = false
|
|
129
|
+
@require_event_payload_schema = false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def set_default_directories
|
|
63
133
|
@guards_dir = 'app/guards'
|
|
64
134
|
@events_dir = 'app/events'
|
|
65
135
|
@schemas_dir = 'app/schemas'
|
|
66
136
|
@services_dir = 'app/services'
|
|
67
|
-
|
|
68
|
-
@strict_event_validation = true
|
|
69
|
-
@include_default_guards = true
|
|
137
|
+
@tests_dir = 'spec'
|
|
70
138
|
end
|
|
71
139
|
|
|
72
140
|
# Returns the full path to a service's schema file.
|
data/lib/servus/events/bus.rb
CHANGED
|
@@ -91,6 +91,35 @@ module Servus
|
|
|
91
91
|
ActiveSupport::Notifications.instrument(notification_name(event_name), payload)
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
# Subscribes to all Servus event emissions.
|
|
95
|
+
#
|
|
96
|
+
# Yields the clean event name and payload as positional args, plus
|
|
97
|
+
# +started_at+, +finished_at+, and +id+ as keyword args.
|
|
98
|
+
# Returns the subscription for manual unsubscribe.
|
|
99
|
+
#
|
|
100
|
+
# @yield [event_name, payload, started_at:, finished_at:, id:]
|
|
101
|
+
# @yieldparam event_name [Symbol] the event name
|
|
102
|
+
# @yieldparam payload [Hash] the event payload
|
|
103
|
+
# @yieldparam started_at [Time] when the event was emitted
|
|
104
|
+
# @yieldparam finished_at [Time] when the instrumented block completed
|
|
105
|
+
# @yieldparam id [String] unique notification ID
|
|
106
|
+
# @return [Object] the ActiveSupport::Notifications subscription
|
|
107
|
+
#
|
|
108
|
+
# @example Forward all events to an external system
|
|
109
|
+
# Servus::Events::Bus.subscribe_all do |event_name, payload, started_at:, **|
|
|
110
|
+
# EventusForwardJob.perform_later(
|
|
111
|
+
# event: event_name.to_s,
|
|
112
|
+
# payload: payload.as_json,
|
|
113
|
+
# occurred_at: started_at.utc.iso8601(6)
|
|
114
|
+
# )
|
|
115
|
+
# end
|
|
116
|
+
def subscribe_all(&block)
|
|
117
|
+
ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, started, finished, id, payload|
|
|
118
|
+
event_name = name.delete_prefix('servus.events.').to_sym
|
|
119
|
+
block.call(event_name, payload, started_at: started, finished_at: finished, id: id)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
94
123
|
# Clears all registered handlers and unsubscribes from notifications.
|
|
95
124
|
#
|
|
96
125
|
# Useful for testing and development mode reloading.
|
|
@@ -127,6 +127,8 @@ 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)
|
|
131
|
+
Servus::Support::Logger.log_event(emission[:event_name], payload)
|
|
130
132
|
Servus::Events::Bus.emit(emission[:event_name], payload)
|
|
131
133
|
end
|
|
132
134
|
end
|
|
@@ -134,6 +136,19 @@ module Servus
|
|
|
134
136
|
# Instance methods for emitting events during service execution
|
|
135
137
|
private
|
|
136
138
|
|
|
139
|
+
# Validates the payload against all handler schemas registered for the event.
|
|
140
|
+
#
|
|
141
|
+
# @param event_name [Symbol] the event name
|
|
142
|
+
# @param payload [Hash] the event payload
|
|
143
|
+
# @return [void]
|
|
144
|
+
# @raise [Servus::Support::Errors::ValidationError] if payload fails validation
|
|
145
|
+
# @api private
|
|
146
|
+
def validate_event_payload!(event_name, payload)
|
|
147
|
+
Servus::Events::Bus.handlers_for(event_name).each do |handler_class|
|
|
148
|
+
Servus::Support::Validator.validate_event_payload!(handler_class, payload)
|
|
149
|
+
end
|
|
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,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Extensions
|
|
5
|
+
module Lazily
|
|
6
|
+
# Provides lazy record resolution for service inputs.
|
|
7
|
+
#
|
|
8
|
+
# This module extends {Servus::Base} with the {#lazily} class method, enabling
|
|
9
|
+
# services to accept either a record ID or an already-loaded record instance.
|
|
10
|
+
# Resolution happens lazily on first access and is memoized.
|
|
11
|
+
#
|
|
12
|
+
# @see Lazily#lazily
|
|
13
|
+
module Call
|
|
14
|
+
# Declares a lazy record resolver for a service input.
|
|
15
|
+
#
|
|
16
|
+
# Defines an accessor method that lazily resolves the input value to a record.
|
|
17
|
+
# If the value is already an instance of the target class, it is returned directly.
|
|
18
|
+
# If the value is an ID (or other lookup value), it is resolved via the target
|
|
19
|
+
# class's +.find+ or +.find_by!+ method. Arrays are resolved via +.where+.
|
|
20
|
+
#
|
|
21
|
+
# The resolved record is written back to the instance variable, so subsequent
|
|
22
|
+
# calls return the same object without re-querying.
|
|
23
|
+
#
|
|
24
|
+
# @param name [Symbol] the param/ivar name, also becomes the accessor method
|
|
25
|
+
# @param finds [Class] the model class to resolve against (e.g., +User+, +Account+)
|
|
26
|
+
# @param by [Symbol] the lookup column (default: +:id+). When +:id+, uses +.find+.
|
|
27
|
+
# Otherwise uses +.find_by!(column: value)+.
|
|
28
|
+
# @return [void]
|
|
29
|
+
#
|
|
30
|
+
# @example Basic usage with .find
|
|
31
|
+
# lazily :user, finds: User
|
|
32
|
+
# # user: 123 → User.find(123)
|
|
33
|
+
# # user: user_inst → returns user_inst directly
|
|
34
|
+
#
|
|
35
|
+
# @example Custom column lookup
|
|
36
|
+
# lazily :account, finds: Account, by: :uuid
|
|
37
|
+
# # account: "abc-def" → Account.find_by!(uuid: "abc-def")
|
|
38
|
+
#
|
|
39
|
+
# @example Array input
|
|
40
|
+
# lazily :users, finds: User
|
|
41
|
+
# # users: [1, 2, 3] → User.where(id: [1, 2, 3])
|
|
42
|
+
#
|
|
43
|
+
# @note Only available when ActiveRecord is loaded (via Railtie)
|
|
44
|
+
# @see Servus::Base.call
|
|
45
|
+
def lazily(name, finds:, by: :id)
|
|
46
|
+
(@lazy_resolvers ||= {})[name] = { klass: finds, by: by }
|
|
47
|
+
define_resolver_method(name, finds, by)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the hash of registered lazy resolvers for this service class.
|
|
51
|
+
#
|
|
52
|
+
# @return [Hash{Symbol => Hash}] resolver configurations keyed by name
|
|
53
|
+
# @api private
|
|
54
|
+
def lazy_resolvers
|
|
55
|
+
@lazy_resolvers || {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Defines a lazy accessor method on a prepended module.
|
|
61
|
+
#
|
|
62
|
+
# @param name [Symbol] the method/ivar name
|
|
63
|
+
# @param klass [Class] the target model class
|
|
64
|
+
# @param by [Symbol] the lookup column
|
|
65
|
+
# @api private
|
|
66
|
+
def define_resolver_method(name, klass, by)
|
|
67
|
+
mod = (@_resolver_module ||= Module.new)
|
|
68
|
+
prepend(mod) unless ancestors.include?(mod)
|
|
69
|
+
|
|
70
|
+
mod.define_method(name) do
|
|
71
|
+
@_lazily_resolved ||= {}
|
|
72
|
+
return instance_variable_get(:"@#{name}") if @_lazily_resolved[name]
|
|
73
|
+
|
|
74
|
+
resolved = Resolver.call(instance_variable_get(:"@#{name}"), klass: klass, by: by, name: name)
|
|
75
|
+
@_lazily_resolved[name] = true
|
|
76
|
+
instance_variable_set(:"@#{name}", resolved)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Extensions
|
|
5
|
+
module Lazily
|
|
6
|
+
# Error classes for lazy record resolution.
|
|
7
|
+
#
|
|
8
|
+
# These errors are raised when lazy resolution fails, such as when
|
|
9
|
+
# a required record reference is nil.
|
|
10
|
+
module Errors
|
|
11
|
+
# Base error class for all lazily extension errors.
|
|
12
|
+
#
|
|
13
|
+
# All lazy resolution errors inherit from this class for easy rescue handling.
|
|
14
|
+
class LazilyError < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Raised when a lazily-resolved record reference is nil.
|
|
17
|
+
#
|
|
18
|
+
# This occurs when a service declares +lazily :user, finds: User+ but
|
|
19
|
+
# receives +user: nil+. A nil reference is always a bug at the call site.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# class MyService < Servus::Base
|
|
23
|
+
# lazily :user, finds: User
|
|
24
|
+
#
|
|
25
|
+
# def initialize(user:)
|
|
26
|
+
# @user = user
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# def call
|
|
30
|
+
# user # => raises NotFoundError if @user is nil
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
class NotFoundError < LazilyError; end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Extensions
|
|
5
|
+
# Lazy record resolution extensions for Servus services.
|
|
6
|
+
#
|
|
7
|
+
# This module provides the infrastructure for lazily resolving record
|
|
8
|
+
# references (IDs or instances) in service inputs. When loaded, it extends
|
|
9
|
+
# {Servus::Base} with the {Call#lazily} class method.
|
|
10
|
+
#
|
|
11
|
+
# @see Servus::Extensions::Lazily::Call
|
|
12
|
+
module Lazily
|
|
13
|
+
require 'servus/extensions/lazily/errors'
|
|
14
|
+
require 'servus/extensions/lazily/resolver'
|
|
15
|
+
require 'servus/extensions/lazily/call'
|
|
16
|
+
|
|
17
|
+
# Extension module for lazily functionality.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
module Ext; end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Extensions
|
|
5
|
+
module Lazily
|
|
6
|
+
# Performs the actual record resolution for a lazily-declared input.
|
|
7
|
+
#
|
|
8
|
+
# Handles the decision logic: is the raw value already an instance,
|
|
9
|
+
# nil, an array, or a lookup value? Delegates to the appropriate
|
|
10
|
+
# finder method on the target class.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class Resolver
|
|
14
|
+
# Resolves a raw value to a record (or collection of records).
|
|
15
|
+
#
|
|
16
|
+
# @param raw [Object] the raw input value (ID, instance, Array, or nil)
|
|
17
|
+
# @param klass [Class] the target model class
|
|
18
|
+
# @param by [Symbol] the lookup column
|
|
19
|
+
# @param name [Symbol] the param name (for error messages)
|
|
20
|
+
# @return [Object] the resolved record or collection
|
|
21
|
+
# @raise [Errors::NotFoundError] if raw is nil
|
|
22
|
+
def self.call(raw, klass:, by:, name:)
|
|
23
|
+
return raw if raw.is_a?(klass)
|
|
24
|
+
raise Errors::NotFoundError, "Couldn't find #{klass} (#{name} was nil)" if raw.nil?
|
|
25
|
+
return klass.where(by => raw) if raw.is_a?(Array)
|
|
26
|
+
|
|
27
|
+
by == :id ? klass.find(raw) : klass.find_by!(by => raw)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
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
|
|