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
|
@@ -39,6 +39,46 @@ module Servus
|
|
|
39
39
|
render_service_error(@result.error) unless @result.success?
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Executes a service and returns its data on success, raising the
|
|
43
|
+
# failure's error otherwise.
|
|
44
|
+
#
|
|
45
|
+
# The bang counterpart to {#run_service}. Use it outside a standard
|
|
46
|
+
# controller render flow — inside background logic, callbacks, or any
|
|
47
|
+
# place where a failure should propagate as an exception rather than be
|
|
48
|
+
# rendered as JSON.
|
|
49
|
+
#
|
|
50
|
+
# Inside a service's `#call` method, use {Servus::Base#call!} instead —
|
|
51
|
+
# it preserves the failure Response for the outer service's caller rather
|
|
52
|
+
# than raising.
|
|
53
|
+
#
|
|
54
|
+
# Mirrors {#run_service}: stores the full Response in @result so views
|
|
55
|
+
# and downstream helpers can reach for it the same way, then returns the
|
|
56
|
+
# data on success or raises on failure. The only behavioural difference
|
|
57
|
+
# between the two is raise-vs-render on failure.
|
|
58
|
+
#
|
|
59
|
+
# Sugar over:
|
|
60
|
+
#
|
|
61
|
+
# @result = Service.call(**params)
|
|
62
|
+
# raise @result.error unless @result.success?
|
|
63
|
+
# data = @result.data
|
|
64
|
+
#
|
|
65
|
+
# @example From a rake task
|
|
66
|
+
# data = run_service!(Treasury::Reconcile::Service, date: Date.current)
|
|
67
|
+
#
|
|
68
|
+
# @param klass [Class<Servus::Base>] service class to execute
|
|
69
|
+
# @param params [Hash] keyword arguments to pass to the service
|
|
70
|
+
# @return [Servus::Support::DataObject, Object] the service's data on success
|
|
71
|
+
# @raise [Servus::Support::Errors::ServiceError] the failure's error otherwise
|
|
72
|
+
#
|
|
73
|
+
# @see #run_service
|
|
74
|
+
# @see Servus::Base#call!
|
|
75
|
+
def run_service!(klass, **params)
|
|
76
|
+
@result = klass.call(**params)
|
|
77
|
+
return @result.data if @result.success?
|
|
78
|
+
|
|
79
|
+
raise @result.error
|
|
80
|
+
end
|
|
81
|
+
|
|
42
82
|
# Renders a service error as a JSON response.
|
|
43
83
|
#
|
|
44
84
|
# Uses error.http_status for the response status code and
|
data/lib/servus/railtie.rb
CHANGED
|
@@ -14,11 +14,17 @@ module Servus
|
|
|
14
14
|
initializer 'servus.job_async' do
|
|
15
15
|
ActiveSupport.on_load(:active_job) do
|
|
16
16
|
require 'servus/extensions/async/ext'
|
|
17
|
-
# Extend the base service with the async call method
|
|
18
17
|
Servus::Base.extend Servus::Extensions::Async::Call
|
|
19
18
|
end
|
|
20
19
|
end
|
|
21
20
|
|
|
21
|
+
initializer 'servus.lazily' do
|
|
22
|
+
ActiveSupport.on_load(:active_record) do
|
|
23
|
+
require 'servus/extensions/lazily/ext'
|
|
24
|
+
Servus::Base.extend Servus::Extensions::Lazily::Call
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
22
28
|
# Load guards and event handlers, clear caches on reload
|
|
23
29
|
config.to_prepare do
|
|
24
30
|
# Load custom guards from guards_dir
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'delegate'
|
|
4
|
+
|
|
5
|
+
module Servus
|
|
6
|
+
module Support
|
|
7
|
+
# A read-only wrapper around Hash data that provides accessor-style access.
|
|
8
|
+
#
|
|
9
|
+
# When service results contain Hash data, +DataObject+ wraps it so keys can be
|
|
10
|
+
# accessed as methods in addition to the standard bracket syntax. Nested Hashes
|
|
11
|
+
# are recursively wrapped, enabling chained access like +data.user.address.city+.
|
|
12
|
+
#
|
|
13
|
+
# Non-Hash values (nil, String, Integer, Array, model instances) pass through
|
|
14
|
+
# unwrapped. This means +data.user+ returns the original object when +user+ is
|
|
15
|
+
# an ActiveRecord model, allowing natural method chaining (+data.user.email+).
|
|
16
|
+
#
|
|
17
|
+
# +DataObject+ inherits from +SimpleDelegator+, so all standard Hash methods
|
|
18
|
+
# (+[]+, +keys+, +each+, +as_json+, +==+, etc.) are delegated transparently.
|
|
19
|
+
#
|
|
20
|
+
# @example Accessor-style access
|
|
21
|
+
# data = DataObject.wrap({ user: { email: "alice@example.com" } })
|
|
22
|
+
# data.user.email # => "alice@example.com"
|
|
23
|
+
# data[:user] # => { email: "alice@example.com" } (plain Hash)
|
|
24
|
+
#
|
|
25
|
+
# @example Mixed values
|
|
26
|
+
# data = DataObject.wrap({ user: user_model, metadata: { source: "api" } })
|
|
27
|
+
# data.user.email # => delegates to model's #email method
|
|
28
|
+
# data.metadata.source # => "api" (wrapped Hash accessor)
|
|
29
|
+
#
|
|
30
|
+
# @see Servus::Support::Response
|
|
31
|
+
class DataObject < SimpleDelegator
|
|
32
|
+
# Wraps a value in a DataObject if it is a Hash.
|
|
33
|
+
#
|
|
34
|
+
# Non-Hash values are returned unchanged. This is the preferred way to
|
|
35
|
+
# create DataObject instances, as it handles nil and non-Hash types safely.
|
|
36
|
+
#
|
|
37
|
+
# @param data [Object] the value to potentially wrap
|
|
38
|
+
# @return [DataObject] if data is a Hash
|
|
39
|
+
# @return [Array] with Hash elements wrapped if data is an Array
|
|
40
|
+
# @return [Object] the original value otherwise
|
|
41
|
+
def self.wrap(data)
|
|
42
|
+
case data
|
|
43
|
+
when Hash then new(data)
|
|
44
|
+
when Array then data.map { |item| wrap(item) }
|
|
45
|
+
else data
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Provides accessor-style access to Hash keys.
|
|
52
|
+
#
|
|
53
|
+
# Only zero-argument, no-block calls trigger key lookup. Methods with
|
|
54
|
+
# arguments (e.g., +fetch+, +dig+) delegate to Hash normally.
|
|
55
|
+
# When the value is a Hash, it is recursively wrapped in a DataObject.
|
|
56
|
+
#
|
|
57
|
+
# @param method_name [Symbol] the method name to look up as a key
|
|
58
|
+
# @return [Object] the value for the key, wrapped if it is a Hash
|
|
59
|
+
# @raise [NoMethodError] if the key does not exist
|
|
60
|
+
def method_missing(method_name, *args, &block)
|
|
61
|
+
return super if args.any? || block
|
|
62
|
+
|
|
63
|
+
hash = __getobj__
|
|
64
|
+
if hash.key?(method_name.to_sym)
|
|
65
|
+
self.class.wrap(hash[method_name.to_sym])
|
|
66
|
+
elsif hash.key?(method_name.to_s)
|
|
67
|
+
self.class.wrap(hash[method_name.to_s])
|
|
68
|
+
else
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
74
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
75
|
+
hash = __getobj__
|
|
76
|
+
hash.key?(method_name.to_sym) || hash.key?(method_name.to_s) || super
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -267,6 +267,22 @@ module Servus
|
|
|
267
267
|
def api_error = { code: http_status, message: message }
|
|
268
268
|
end
|
|
269
269
|
|
|
270
|
+
# Raised when a service or event handler is invoked without a required schema.
|
|
271
|
+
#
|
|
272
|
+
# Triggered by the +require_service_arguments_schema+,
|
|
273
|
+
# +require_service_result_schema+, or +require_event_payload_schema+
|
|
274
|
+
# configuration flags.
|
|
275
|
+
#
|
|
276
|
+
# @see Servus::Config#require_service_arguments_schema
|
|
277
|
+
# @see Servus::Config#require_service_result_schema
|
|
278
|
+
# @see Servus::Config#require_event_payload_schema
|
|
279
|
+
class SchemaRequiredError < ServiceError
|
|
280
|
+
DEFAULT_MESSAGE = 'Schema is required but not defined'
|
|
281
|
+
|
|
282
|
+
def http_status = :unprocessable_entity
|
|
283
|
+
def api_error = { code: :schema_required, message: message }
|
|
284
|
+
end
|
|
285
|
+
|
|
270
286
|
# 423 Locked - resource is locked.
|
|
271
287
|
class LockedError < ServiceError
|
|
272
288
|
DEFAULT_MESSAGE = 'Locked'
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Servus
|
|
4
|
+
module Support
|
|
5
|
+
# Enforces that services are invoked through {Servus::Base.call} rather
|
|
6
|
+
# than by instantiating a service and calling its instance `#call`
|
|
7
|
+
# directly. The class-level `.call` runs argument validation, logging,
|
|
8
|
+
# benchmarking, guard handling, result validation, and event emission;
|
|
9
|
+
# calling the instance method directly would silently skip all of that.
|
|
10
|
+
#
|
|
11
|
+
# When included in {Servus::Base}, this module:
|
|
12
|
+
# - Privatizes `.new` on the base class (and, by inheritance, on every
|
|
13
|
+
# descendant) so `MyService.new` from outside the class raises
|
|
14
|
+
# `NoMethodError`.
|
|
15
|
+
# - Installs a `method_added` hook on every descendant that privatizes
|
|
16
|
+
# any instance-level `#call` at definition time.
|
|
17
|
+
#
|
|
18
|
+
# Controlled by {Servus::Config#lockdown_enabled} (default `true`). Set
|
|
19
|
+
# it to `false` to allow direct instantiation and public instance
|
|
20
|
+
# `#call` — useful if you have existing code that relies on those entry
|
|
21
|
+
# points, or if you prefer to opt out of this enforcement entirely.
|
|
22
|
+
#
|
|
23
|
+
# @example Opting out
|
|
24
|
+
# Servus.configure do |config|
|
|
25
|
+
# config.lockdown_enabled = false
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @see Servus::Config#lockdown_enabled
|
|
29
|
+
module Lockdown
|
|
30
|
+
# Wires the lockdown hooks into the including class.
|
|
31
|
+
#
|
|
32
|
+
# Extends the base with {ClassMethods} (for {ClassMethods#apply_lockdown!}),
|
|
33
|
+
# prepends {Inherited} so subclasses receive the `method_added` hook,
|
|
34
|
+
# and applies the current config value to `.new`'s visibility.
|
|
35
|
+
#
|
|
36
|
+
# @param base [Class] the class including this module (expected to be {Servus::Base})
|
|
37
|
+
# @return [void]
|
|
38
|
+
# @api private
|
|
39
|
+
def self.included(base)
|
|
40
|
+
base.extend(ClassMethods)
|
|
41
|
+
base.singleton_class.prepend(Inherited)
|
|
42
|
+
base.apply_lockdown!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Prepended onto the base class's singleton so that every subclass of
|
|
46
|
+
# {Servus::Base} is automatically extended with {PrivateCall} at
|
|
47
|
+
# class-definition time.
|
|
48
|
+
#
|
|
49
|
+
# @api private
|
|
50
|
+
module Inherited
|
|
51
|
+
# Ensures each subclass has the `method_added` hook installed.
|
|
52
|
+
#
|
|
53
|
+
# @param subclass [Class] the newly defined subclass
|
|
54
|
+
# @return [void]
|
|
55
|
+
def inherited(subclass)
|
|
56
|
+
super
|
|
57
|
+
subclass.extend(PrivateCall)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Extended onto every {Servus::Base} subclass. Privatizes any
|
|
62
|
+
# instance-level `#call` as soon as it is defined, provided lockdown
|
|
63
|
+
# is enabled in config at definition time.
|
|
64
|
+
#
|
|
65
|
+
# @api private
|
|
66
|
+
module PrivateCall
|
|
67
|
+
# @param name [Symbol] the name of the newly added method
|
|
68
|
+
# @return [void]
|
|
69
|
+
def method_added(name)
|
|
70
|
+
super
|
|
71
|
+
return unless Servus.config.lockdown_enabled
|
|
72
|
+
|
|
73
|
+
private :call if name == :call && public_method_defined?(:call)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Class-level helpers installed on {Servus::Base}.
|
|
78
|
+
module ClassMethods
|
|
79
|
+
# Applies {Servus::Config#lockdown_enabled} to `.new`'s visibility.
|
|
80
|
+
# Called on include and re-called whenever the config flag changes.
|
|
81
|
+
#
|
|
82
|
+
# @return [void]
|
|
83
|
+
# @api private
|
|
84
|
+
def apply_lockdown!
|
|
85
|
+
if Servus.config.lockdown_enabled
|
|
86
|
+
singleton_class.send(:private, :new)
|
|
87
|
+
else
|
|
88
|
+
singleton_class.send(:public, :new)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -55,6 +55,22 @@ module Servus
|
|
|
55
55
|
logger.warn("#{service_class.name} failed in #{duration.round(3)}s with error: #{error}")
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Logs a guard failure from a service
|
|
59
|
+
#
|
|
60
|
+
# @param service_class [Class] The service class
|
|
61
|
+
# @param error [Servus::Support::Errors::GuardError] The guard error
|
|
62
|
+
def self.log_guard_failure(service_class, error)
|
|
63
|
+
logger.warn("#{service_class.name} guard failed: #{error.message}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Logs an event emission
|
|
67
|
+
#
|
|
68
|
+
# @param event_name [Symbol] The event name
|
|
69
|
+
# @param payload [Hash] The event payload
|
|
70
|
+
def self.log_event(event_name, payload)
|
|
71
|
+
logger.info("Event :#{event_name} emitted with payload: #{payload.inspect}")
|
|
72
|
+
end
|
|
73
|
+
|
|
58
74
|
# Logs a validation error from a service
|
|
59
75
|
#
|
|
60
76
|
# @param service_class [Class] The service class
|
|
@@ -51,7 +51,7 @@ module Servus
|
|
|
51
51
|
# @api private
|
|
52
52
|
def initialize(success, data, error)
|
|
53
53
|
@success = success
|
|
54
|
-
@data = data
|
|
54
|
+
@data = DataObject.wrap(data)
|
|
55
55
|
@error = error
|
|
56
56
|
end
|
|
57
57
|
|
|
@@ -69,6 +69,17 @@ module Servus
|
|
|
69
69
|
def success?
|
|
70
70
|
@success
|
|
71
71
|
end
|
|
72
|
+
|
|
73
|
+
# Checks if the service execution failed.
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean] true if the service failed, false if it succeeded
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# result = MyService.call(params)
|
|
79
|
+
# return render_error(result.error.message) if result.failure?
|
|
80
|
+
def failure?
|
|
81
|
+
!@success
|
|
82
|
+
end
|
|
72
83
|
end
|
|
73
84
|
end
|
|
74
85
|
end
|
|
@@ -45,24 +45,23 @@ module Servus
|
|
|
45
45
|
# @api private
|
|
46
46
|
def self.validate_arguments!(service_class, args)
|
|
47
47
|
schema = load_schema(service_class, 'arguments')
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
serialized_result = args.as_json
|
|
51
|
-
validation_errors = JSON::Validator.fully_validate(schema, serialized_result)
|
|
48
|
+
enforce_schema_presence!(schema, service_class, :require_service_arguments_schema)
|
|
49
|
+
return true unless schema
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
validate_data_against_schema!(
|
|
52
|
+
args,
|
|
53
|
+
schema,
|
|
54
|
+
"Invalid arguments for #{service_class.name}"
|
|
55
|
+
)
|
|
57
56
|
|
|
58
57
|
true
|
|
59
58
|
end
|
|
60
59
|
|
|
61
|
-
# Validates service result data against the
|
|
60
|
+
# Validates service result data against the appropriate schema.
|
|
62
61
|
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
62
|
+
# For successful responses, validates against the +result+ schema.
|
|
63
|
+
# For failure responses with data, validates against the +failure+ schema.
|
|
64
|
+
# Failure responses without data are skipped.
|
|
66
65
|
#
|
|
67
66
|
# @param service_class [Class] the service class being validated
|
|
68
67
|
# @param result [Servus::Support::Response] the response object to validate
|
|
@@ -74,20 +73,33 @@ module Servus
|
|
|
74
73
|
#
|
|
75
74
|
# @api private
|
|
76
75
|
def self.validate_result!(service_class, result)
|
|
77
|
-
|
|
76
|
+
schema, schema_type = result_schema_for(service_class, result)
|
|
77
|
+
return result unless schema
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
validate_data_against_schema!(
|
|
80
|
+
result.data,
|
|
81
|
+
schema,
|
|
82
|
+
"Invalid #{schema_type} structure from #{service_class.name}"
|
|
83
|
+
)
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
result
|
|
86
|
+
end
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
# Resolves the schema and type label for a service result.
|
|
89
|
+
#
|
|
90
|
+
# @param service_class [Class] the service class
|
|
91
|
+
# @param result [Servus::Support::Response] the response object
|
|
92
|
+
# @return [Array(Hash, String), Array(nil, nil)] the schema and type label
|
|
93
|
+
#
|
|
94
|
+
# @api private
|
|
95
|
+
def self.result_schema_for(service_class, result)
|
|
96
|
+
if result.success?
|
|
97
|
+
schema = load_schema(service_class, 'result')
|
|
98
|
+
enforce_schema_presence!(schema, service_class, :require_service_result_schema)
|
|
99
|
+
[schema, 'result']
|
|
100
|
+
elsif result.data
|
|
101
|
+
[load_schema(service_class, 'failure'), 'failure']
|
|
88
102
|
end
|
|
89
|
-
|
|
90
|
-
result
|
|
91
103
|
end
|
|
92
104
|
|
|
93
105
|
# Validates event payload against the handler's payload schema.
|
|
@@ -104,15 +116,14 @@ module Servus
|
|
|
104
116
|
# @api private
|
|
105
117
|
def self.validate_event_payload!(handler_class, payload)
|
|
106
118
|
schema = handler_class.payload_schema
|
|
119
|
+
enforce_schema_presence!(schema, handler_class, :require_event_payload_schema)
|
|
107
120
|
return true unless schema
|
|
108
121
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"Invalid payload for event :#{handler_class.event_name}: #{validation_errors.join(', ')}"
|
|
115
|
-
end
|
|
122
|
+
validate_data_against_schema!(
|
|
123
|
+
payload,
|
|
124
|
+
schema,
|
|
125
|
+
"Invalid payload for event :#{handler_class.event_name}"
|
|
126
|
+
)
|
|
116
127
|
|
|
117
128
|
true
|
|
118
129
|
end
|
|
@@ -127,7 +138,7 @@ module Servus
|
|
|
127
138
|
# Schemas are cached after first load for performance.
|
|
128
139
|
#
|
|
129
140
|
# @param service_class [Class] the service class
|
|
130
|
-
# @param type [String] schema type ("arguments" or "
|
|
141
|
+
# @param type [String] schema type ("arguments", "result", or "failure")
|
|
131
142
|
# @return [Hash, nil] the schema hash, or nil if no schema found
|
|
132
143
|
#
|
|
133
144
|
# @api private
|
|
@@ -141,10 +152,10 @@ module Servus
|
|
|
141
152
|
return @schema_cache[schema_path] if @schema_cache.key?(schema_path)
|
|
142
153
|
|
|
143
154
|
# Check for DSL-defined schema first
|
|
144
|
-
dsl_schema =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
dsl_schema = case type
|
|
156
|
+
when 'arguments' then service_class.arguments_schema
|
|
157
|
+
when 'result' then service_class.result_schema
|
|
158
|
+
when 'failure' then service_class.failure_schema
|
|
148
159
|
end
|
|
149
160
|
|
|
150
161
|
inline_schema_constant_name = "#{service_class}::#{type.upcase}_SCHEMA"
|
|
@@ -180,6 +191,40 @@ module Servus
|
|
|
180
191
|
@schema_cache
|
|
181
192
|
end
|
|
182
193
|
|
|
194
|
+
# Serializes data and validates it against a JSON schema.
|
|
195
|
+
#
|
|
196
|
+
# @param data [Object] the data to validate
|
|
197
|
+
# @param schema [Hash] the JSON schema to validate against
|
|
198
|
+
# @param message_prefix [String] prefix for the error message on failure
|
|
199
|
+
# @return [void]
|
|
200
|
+
# @raise [Servus::Support::Errors::ValidationError] if data fails validation
|
|
201
|
+
#
|
|
202
|
+
# @api private
|
|
203
|
+
def self.validate_data_against_schema!(data, schema, message_prefix)
|
|
204
|
+
errors = JSON::Validator.fully_validate(schema, data.as_json)
|
|
205
|
+
return if errors.empty?
|
|
206
|
+
|
|
207
|
+
raise Servus::Base::ValidationError, "#{message_prefix}: #{errors.join(', ')}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Returns the schema if present. Raises if absent and the config flag is enabled.
|
|
211
|
+
#
|
|
212
|
+
# @param schema [Hash, nil] the loaded schema
|
|
213
|
+
# @param klass [Class] the service or handler class
|
|
214
|
+
# @param config_flag [Symbol] the config method to check
|
|
215
|
+
# @return [Hash, nil] the schema
|
|
216
|
+
# @raise [Servus::Support::Errors::SchemaRequiredError] if schema is nil and enforcement is enabled
|
|
217
|
+
#
|
|
218
|
+
# @api private
|
|
219
|
+
def self.enforce_schema_presence!(schema, klass, config_flag)
|
|
220
|
+
return schema if schema
|
|
221
|
+
|
|
222
|
+
return unless Servus.config.public_send(config_flag)
|
|
223
|
+
|
|
224
|
+
raise Servus::Support::Errors::SchemaRequiredError,
|
|
225
|
+
"#{klass.name} schema missing! #{config_flag} is set to true."
|
|
226
|
+
end
|
|
227
|
+
|
|
183
228
|
# Fetches schema from DSL, inline constant, or file.
|
|
184
229
|
#
|
|
185
230
|
# Implements the schema resolution precedence:
|
|
@@ -116,6 +116,80 @@ module Servus
|
|
|
116
116
|
Servus::Support::Response.new(true, example, nil)
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# Extracts example failure data values from a service's schema.
|
|
120
|
+
#
|
|
121
|
+
# Looks for `example` or `examples` keywords in the service's failure schema
|
|
122
|
+
# and returns them wrapped in a failure Response. Useful for validating failure
|
|
123
|
+
# response structure in tests.
|
|
124
|
+
#
|
|
125
|
+
# @param service_class [Class] The service class to extract examples from
|
|
126
|
+
# @param overrides [Hash] Optional values to override the schema examples
|
|
127
|
+
# @return [Servus::Support::Response] Failure response object with example data
|
|
128
|
+
#
|
|
129
|
+
# @example Basic usage
|
|
130
|
+
# expected = servus_failure_example(ProcessPayment::Service)
|
|
131
|
+
# # => Servus::Support::Response with failure? == true, data:
|
|
132
|
+
# # { reason: 'card_declined', decline_code: 'insufficient_funds' }
|
|
133
|
+
#
|
|
134
|
+
# @note Override keys can be strings or symbols; they'll be converted to symbols
|
|
135
|
+
# @note Returns empty hash if service has no failure schema defined
|
|
136
|
+
def servus_failure_example(service_class, overrides = {})
|
|
137
|
+
example = extract_example_from(service_class, :failure, overrides)
|
|
138
|
+
Servus::Support::Response.new(false, example, Servus::Support::Errors::ServiceError.new)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Builds a successful Response object with the given data.
|
|
142
|
+
#
|
|
143
|
+
# Convenience method for creating successful responses in tests
|
|
144
|
+
# without calling +Servus::Support::Response.new+ directly.
|
|
145
|
+
#
|
|
146
|
+
# @param data [Hash] the success data to wrap in the response
|
|
147
|
+
# @return [Servus::Support::Response] a successful response wrapping the data
|
|
148
|
+
#
|
|
149
|
+
# @example Basic usage
|
|
150
|
+
# response = servus_success_response(transferred: 50)
|
|
151
|
+
# response.success? # => true
|
|
152
|
+
# response.data[:transferred] # => 50
|
|
153
|
+
#
|
|
154
|
+
# @example Stubbing a service call
|
|
155
|
+
# allow(TransferService).to receive(:call).and_return(
|
|
156
|
+
# servus_success_response(transferred: 50)
|
|
157
|
+
# )
|
|
158
|
+
#
|
|
159
|
+
# @see #servus_failure_response
|
|
160
|
+
def servus_success_response(data = {})
|
|
161
|
+
Servus::Support::Response.new(true, data, nil)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Builds a failure Response object with the given message and error type.
|
|
165
|
+
#
|
|
166
|
+
# Convenience method for creating failure responses in tests.
|
|
167
|
+
# Mirrors the signature of {Servus::Base#failure}.
|
|
168
|
+
#
|
|
169
|
+
# @param message [String, nil] the error message (uses error type's default if nil)
|
|
170
|
+
# @param data [Hash, nil] optional structured data to include with the failure
|
|
171
|
+
# @param type [Class] the error class to use (default: ServiceError)
|
|
172
|
+
# @return [Servus::Support::Response] a failure response with the error
|
|
173
|
+
#
|
|
174
|
+
# @example Basic usage
|
|
175
|
+
# response = servus_failure_response("Insufficient funds")
|
|
176
|
+
# response.failure? # => true
|
|
177
|
+
# response.error.message # => "Insufficient funds"
|
|
178
|
+
#
|
|
179
|
+
# @example With custom error type
|
|
180
|
+
# response = servus_failure_response("Not found", type: Servus::Support::Errors::NotFoundError)
|
|
181
|
+
# response.error.http_status # => :not_found
|
|
182
|
+
#
|
|
183
|
+
# @example With structured failure data
|
|
184
|
+
# response = servus_failure_response("Failed", data: { reason: "expired" })
|
|
185
|
+
# response.data[:reason] # => "expired"
|
|
186
|
+
#
|
|
187
|
+
# @see #servus_success_response
|
|
188
|
+
def servus_failure_response(message = nil, data: nil, type: Servus::Support::Errors::ServiceError)
|
|
189
|
+
error = type.new(message)
|
|
190
|
+
Servus::Support::Response.new(false, data, error)
|
|
191
|
+
end
|
|
192
|
+
|
|
119
193
|
private
|
|
120
194
|
|
|
121
195
|
# Helper method to extract and merge examples from schema
|
|
@@ -85,4 +85,103 @@ RSpec::Matchers.define :call_service do |service_class|
|
|
|
85
85
|
"expected #{service_class} to receive #{method}"
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
|
+
|
|
89
|
+
# Matcher for asserting schema presence on a service or event handler
|
|
90
|
+
RSpec::Matchers.define :have_schema do |schema_type|
|
|
91
|
+
match do |klass|
|
|
92
|
+
if schema_type.to_s == 'payload'
|
|
93
|
+
!klass.payload_schema.nil?
|
|
94
|
+
else
|
|
95
|
+
Servus::Support::Validator.clear_cache!
|
|
96
|
+
!Servus::Support::Validator.load_schema(klass, schema_type.to_s).nil?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
failure_message do |klass|
|
|
101
|
+
"expected #{klass.name} to have a #{schema_type} schema defined"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
failure_message_when_negated do |klass|
|
|
105
|
+
"expected #{klass.name} not to have a #{schema_type} schema defined"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Matcher for asserting a successful service response
|
|
110
|
+
RSpec::Matchers.define :be_service_success do
|
|
111
|
+
match do |result|
|
|
112
|
+
@result = result
|
|
113
|
+
result.is_a?(Servus::Support::Response) && result.success?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
failure_message do
|
|
117
|
+
if @result.is_a?(Servus::Support::Response)
|
|
118
|
+
"expected a successful response, but got failure with error: #{@result.error&.message}"
|
|
119
|
+
else
|
|
120
|
+
"expected a Servus::Support::Response, got #{@result.class}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
failure_message_when_negated do
|
|
125
|
+
'expected a failure response, but got success'
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Matcher for asserting a failed service response with optional error class and message
|
|
130
|
+
RSpec::Matchers.define :be_service_failure do |expected_error_class|
|
|
131
|
+
chain :with_message do |message|
|
|
132
|
+
@expected_message = message
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
match do |result|
|
|
136
|
+
@result = result
|
|
137
|
+
return false unless result.is_a?(Servus::Support::Response) && result.failure?
|
|
138
|
+
return false if expected_error_class && !result.error.is_a?(expected_error_class)
|
|
139
|
+
return false if @expected_message && result.error.message != @expected_message
|
|
140
|
+
|
|
141
|
+
true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
failure_message do
|
|
145
|
+
if !@result.is_a?(Servus::Support::Response)
|
|
146
|
+
"expected a Servus::Support::Response, got #{@result.class}"
|
|
147
|
+
elsif @result.success?
|
|
148
|
+
'expected a failure response, but got success'
|
|
149
|
+
elsif expected_error_class && !@result.error.is_a?(expected_error_class)
|
|
150
|
+
"expected error to be a #{expected_error_class.name}, got #{@result.error.class.name}"
|
|
151
|
+
elsif @expected_message
|
|
152
|
+
"expected error message #{@expected_message.inspect}, got #{@result.error.message.inspect}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Matcher for asserting a guard failure response with optional error code and message
|
|
158
|
+
RSpec::Matchers.define :be_guard_failure do |expected_code|
|
|
159
|
+
chain :with_message do |message|
|
|
160
|
+
@expected_message = message
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
match do |result|
|
|
164
|
+
@result = result
|
|
165
|
+
return false unless result.is_a?(Servus::Support::Response) && result.failure?
|
|
166
|
+
return false unless result.error.is_a?(Servus::Support::Errors::GuardError)
|
|
167
|
+
return false if expected_code && result.error.code != expected_code
|
|
168
|
+
return false if @expected_message && result.error.message != @expected_message
|
|
169
|
+
|
|
170
|
+
true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
failure_message do
|
|
174
|
+
if !@result.is_a?(Servus::Support::Response)
|
|
175
|
+
"expected a Servus::Support::Response, got #{@result.class}"
|
|
176
|
+
elsif @result.success?
|
|
177
|
+
'expected a guard failure response, but got success'
|
|
178
|
+
elsif !@result.error.is_a?(Servus::Support::Errors::GuardError)
|
|
179
|
+
"expected error to be a GuardError, got #{@result.error.class.name}"
|
|
180
|
+
elsif expected_code && @result.error.code != expected_code
|
|
181
|
+
"expected guard error code #{expected_code.inspect}, got #{@result.error.code.inspect}"
|
|
182
|
+
elsif @expected_message
|
|
183
|
+
"expected guard error message #{@expected_message.inspect}, got #{@result.error.message.inspect}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
88
187
|
# rubocop:enable Metrics/BlockLength
|
data/lib/servus/version.rb
CHANGED