servus 0.1.2 → 0.1.4
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/.yardopts +6 -0
- data/CHANGELOG.md +8 -1
- data/IDEAS.md +5 -0
- data/READme.md +147 -42
- data/Rakefile +33 -0
- data/builds/servus-0.1.2.gem +0 -0
- data/builds/servus-0.1.3.gem +0 -0
- data/builds/servus-0.1.4.gem +0 -0
- data/docs/core/1_overview.md +77 -0
- data/docs/core/2_architecture.md +92 -0
- data/docs/core/3_service_objects.md +121 -0
- data/docs/features/1_schema_validation.md +119 -0
- data/docs/features/2_error_handling.md +121 -0
- data/docs/features/3_async_execution.md +81 -0
- data/docs/features/4_logging.md +64 -0
- data/docs/guides/1_common_patterns.md +90 -0
- data/docs/guides/2_migration_guide.md +175 -0
- data/docs/integration/1_configuration.md +51 -0
- data/docs/integration/2_testing.md +164 -0
- data/docs/integration/3_rails_integration.md +99 -0
- data/docs/yard/Servus/Base.html +1645 -0
- data/docs/yard/Servus/Config.html +582 -0
- data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
- data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
- data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
- data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
- data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
- data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
- data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
- data/docs/yard/Servus/Extensions/Async.html +141 -0
- data/docs/yard/Servus/Extensions.html +117 -0
- data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
- data/docs/yard/Servus/Generators.html +115 -0
- data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
- data/docs/yard/Servus/Helpers.html +115 -0
- data/docs/yard/Servus/Railtie.html +134 -0
- data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
- data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
- data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
- data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
- data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
- data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
- data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
- data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
- data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
- data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
- data/docs/yard/Servus/Support/Errors.html +140 -0
- data/docs/yard/Servus/Support/Logger.html +856 -0
- data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
- data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
- data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
- data/docs/yard/Servus/Support/Rescuer.html +267 -0
- data/docs/yard/Servus/Support/Response.html +574 -0
- data/docs/yard/Servus/Support/Validator.html +1150 -0
- data/docs/yard/Servus/Support.html +119 -0
- data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
- data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
- data/docs/yard/Servus/Testing.html +142 -0
- data/docs/yard/Servus.html +343 -0
- data/docs/yard/_index.html +535 -0
- data/docs/yard/class_list.html +54 -0
- data/docs/yard/css/common.css +1 -0
- data/docs/yard/css/full_list.css +58 -0
- data/docs/yard/css/style.css +503 -0
- data/docs/yard/file.1_common_patterns.html +154 -0
- data/docs/yard/file.1_configuration.html +115 -0
- data/docs/yard/file.1_overview.html +142 -0
- data/docs/yard/file.1_schema_validation.html +188 -0
- data/docs/yard/file.2_architecture.html +157 -0
- data/docs/yard/file.2_error_handling.html +190 -0
- data/docs/yard/file.2_migration_guide.html +242 -0
- data/docs/yard/file.2_testing.html +227 -0
- data/docs/yard/file.3_async_execution.html +145 -0
- data/docs/yard/file.3_rails_integration.html +160 -0
- data/docs/yard/file.3_service_objects.html +191 -0
- data/docs/yard/file.4_logging.html +135 -0
- data/docs/yard/file.ErrorHandling.html +190 -0
- data/docs/yard/file.READme.html +674 -0
- data/docs/yard/file.architecture.html +157 -0
- data/docs/yard/file.async_execution.html +145 -0
- data/docs/yard/file.common_patterns.html +154 -0
- data/docs/yard/file.configuration.html +115 -0
- data/docs/yard/file.error_handling.html +190 -0
- data/docs/yard/file.logging.html +135 -0
- data/docs/yard/file.migration_guide.html +242 -0
- data/docs/yard/file.overview.html +142 -0
- data/docs/yard/file.rails_integration.html +160 -0
- data/docs/yard/file.schema_validation.html +188 -0
- data/docs/yard/file.service_objects.html +191 -0
- data/docs/yard/file.testing.html +227 -0
- data/docs/yard/file_list.html +119 -0
- data/docs/yard/frames.html +22 -0
- data/docs/yard/index.html +674 -0
- data/docs/yard/js/app.js +344 -0
- data/docs/yard/js/full_list.js +242 -0
- data/docs/yard/js/jquery.js +4 -0
- data/docs/yard/method_list.html +542 -0
- data/docs/yard/top-level-namespace.html +110 -0
- data/lib/generators/servus/service/service_generator.rb +64 -1
- data/lib/generators/servus/service/templates/service.rb.erb +1 -1
- data/lib/servus/base.rb +258 -57
- data/lib/servus/config.rb +58 -12
- data/lib/servus/extensions/async/call.rb +50 -18
- data/lib/servus/extensions/async/errors.rb +23 -3
- data/lib/servus/extensions/async/ext.rb +10 -2
- data/lib/servus/extensions/async/job.rb +32 -11
- data/lib/servus/helpers/controller_helpers.rb +73 -37
- data/lib/servus/support/errors.rb +135 -45
- data/lib/servus/support/rescuer.rb +189 -36
- data/lib/servus/support/response.rb +49 -7
- data/lib/servus/support/validator.rb +120 -19
- data/lib/servus/testing/example_builders.rb +133 -0
- data/lib/servus/testing/example_extractor.rb +309 -0
- data/lib/servus/testing.rb +17 -0
- data/lib/servus/version.rb +1 -1
- metadata +118 -19
|
@@ -2,74 +2,227 @@
|
|
|
2
2
|
|
|
3
3
|
module Servus
|
|
4
4
|
module Support
|
|
5
|
-
#
|
|
5
|
+
# Provides automatic error handling for services via {ClassMethods#rescue_from}.
|
|
6
|
+
#
|
|
7
|
+
# This module enables services to declare which exceptions should be automatically
|
|
8
|
+
# caught and converted to failure responses, eliminating repetitive rescue blocks.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# class MyService < Servus::Base
|
|
12
|
+
# rescue_from Net::HTTPError, Timeout::Error,
|
|
13
|
+
# use: Servus::Support::Errors::ServiceUnavailableError
|
|
14
|
+
#
|
|
15
|
+
# def call
|
|
16
|
+
# make_external_api_call # May raise Net::HTTPError
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @see ClassMethods#rescue_from
|
|
6
21
|
module Rescuer
|
|
7
|
-
#
|
|
22
|
+
# Sets up error rescue functionality when included.
|
|
8
23
|
#
|
|
9
|
-
# @param base [Class]
|
|
24
|
+
# @param base [Class] the class including this module (typically {Servus::Base})
|
|
25
|
+
# @api private
|
|
10
26
|
def self.included(base)
|
|
11
|
-
base.class_attribute :
|
|
12
|
-
base.class_attribute :rescuable_error_type, default: nil
|
|
27
|
+
base.class_attribute :rescuable_configs, default: []
|
|
13
28
|
base.singleton_class.prepend(CallOverride)
|
|
14
29
|
base.extend(ClassMethods)
|
|
15
30
|
end
|
|
16
31
|
|
|
32
|
+
# Provides success/failure methods to rescue_from blocks.
|
|
33
|
+
#
|
|
34
|
+
# This context is used when a rescue_from block is executed. It provides
|
|
35
|
+
# the same success() and failure() methods available in service call methods,
|
|
36
|
+
# allowing blocks to create appropriate Response objects.
|
|
37
|
+
#
|
|
38
|
+
# @api private
|
|
39
|
+
class BlockContext
|
|
40
|
+
def initialize
|
|
41
|
+
@result = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Creates a success response.
|
|
45
|
+
#
|
|
46
|
+
# Use this in rescue_from blocks to recover from exceptions and return
|
|
47
|
+
# successful results despite the error being raised.
|
|
48
|
+
#
|
|
49
|
+
# @param data [Hash, Object] The success data to return
|
|
50
|
+
# @return [Servus::Support::Response] Success response
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# rescue_from SomeError do |exception|
|
|
54
|
+
# success(recovered: true, original_error: exception.message)
|
|
55
|
+
# end
|
|
56
|
+
def success(data = nil)
|
|
57
|
+
@result = Response.new(true, data, nil)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Creates a failure response.
|
|
61
|
+
#
|
|
62
|
+
# Use this in rescue_from blocks to convert exceptions into business failures
|
|
63
|
+
# with custom messages and error types.
|
|
64
|
+
#
|
|
65
|
+
# @param message [String, nil] The error message (uses error type's DEFAULT_MESSAGE if nil)
|
|
66
|
+
# @param type [Class<Servus::Support::Errors::ServiceError>] The error type
|
|
67
|
+
# @return [Servus::Support::Response] Failure response
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# rescue_from ActiveRecord::RecordInvalid do |exception|
|
|
71
|
+
# failure("Database error: #{exception.message}", type: InternalServerError)
|
|
72
|
+
# end
|
|
73
|
+
def failure(message = nil, type: Servus::Support::Errors::ServiceError)
|
|
74
|
+
error = type.new(message)
|
|
75
|
+
@result = Response.new(false, nil, error)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The response created by success() or failure().
|
|
79
|
+
#
|
|
80
|
+
# @return [Servus::Support::Response, nil] The response, or nil if neither method was called
|
|
81
|
+
# @api private
|
|
82
|
+
attr_reader :result
|
|
83
|
+
end
|
|
84
|
+
|
|
17
85
|
# Class methods for rescue_from
|
|
18
86
|
module ClassMethods
|
|
19
|
-
#
|
|
87
|
+
# Configures automatic error handling for the service.
|
|
20
88
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
89
|
+
# Declares which exception classes should be automatically rescued and converted
|
|
90
|
+
# to failure responses. Without a block, exceptions are wrapped in the specified
|
|
91
|
+
# ServiceError type with a formatted message including the original exception details.
|
|
92
|
+
#
|
|
93
|
+
# When a block is provided, it receives the exception and must return either
|
|
94
|
+
# `success(data)` or `failure(message, type:)` to create the response.
|
|
95
|
+
#
|
|
96
|
+
# @example Basic usage with default error type:
|
|
97
|
+
# class TestService < Servus::Base
|
|
98
|
+
# rescue_from Net::HTTPError, Timeout::Error, use: ServiceUnavailableError
|
|
99
|
+
# end
|
|
100
|
+
#
|
|
101
|
+
# @example Custom error handling with block:
|
|
102
|
+
# class TestService < Servus::Base
|
|
103
|
+
# rescue_from ActiveRecord::RecordInvalid do |exception|
|
|
104
|
+
# failure("Validation failed: #{exception.message}", type: ValidationError)
|
|
105
|
+
# end
|
|
106
|
+
# end
|
|
24
107
|
#
|
|
25
|
-
# @example:
|
|
108
|
+
# @example Recovering from errors with success:
|
|
26
109
|
# class TestService < Servus::Base
|
|
27
|
-
# rescue_from
|
|
110
|
+
# rescue_from Stripe::CardError do |exception|
|
|
111
|
+
# if exception.code == 'card_declined'
|
|
112
|
+
# failure("Card declined", type: BadRequestError)
|
|
113
|
+
# else
|
|
114
|
+
# success(recovered: true, fallback_used: true)
|
|
115
|
+
# end
|
|
116
|
+
# end
|
|
28
117
|
# end
|
|
29
118
|
#
|
|
30
|
-
# @param [
|
|
31
|
-
# @param [
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
119
|
+
# @param errors [Class<StandardError>] One or more exception classes to rescue from
|
|
120
|
+
# @param use [Class<Servus::Support::Errors::ServiceError>] Error class to use when wrapping exceptions
|
|
121
|
+
# (only used without block)
|
|
122
|
+
# @yield [exception] Optional block for custom error handling
|
|
123
|
+
# @yieldparam exception [StandardError] The caught exception
|
|
124
|
+
# @yieldreturn [Servus::Support::Response] Must return success() or failure() response
|
|
125
|
+
def rescue_from(*errors, use: Servus::Support::Errors::ServiceError, &block)
|
|
126
|
+
config = {
|
|
127
|
+
errors: errors,
|
|
128
|
+
error_type: use,
|
|
129
|
+
handler: block
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Add to rescuable_configs array
|
|
133
|
+
self.rescuable_configs = rescuable_configs + [config]
|
|
35
134
|
end
|
|
36
135
|
end
|
|
37
136
|
|
|
38
|
-
#
|
|
137
|
+
# Wraps the service's .call method with error handling logic.
|
|
138
|
+
#
|
|
139
|
+
# This module is prepended to the service's singleton class, allowing it to
|
|
140
|
+
# intercept calls and add rescue behavior before delegating to the original implementation.
|
|
141
|
+
#
|
|
142
|
+
# @api private
|
|
39
143
|
module CallOverride
|
|
40
|
-
#
|
|
144
|
+
# Wraps the service call with automatic error rescue.
|
|
41
145
|
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
146
|
+
# If rescuable_errors are configured, wraps the call in a rescue block.
|
|
147
|
+
# Caught exceptions are converted to failure responses using {#handle_failure}.
|
|
148
|
+
#
|
|
149
|
+
# @param args [Hash] keyword arguments passed to the service
|
|
150
|
+
# @return [Servus::Support::Response] the service result or failure response
|
|
151
|
+
#
|
|
152
|
+
# @api private
|
|
44
153
|
def call(**args)
|
|
45
|
-
if
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
rescue *rescuable_errors => e
|
|
49
|
-
handle_failure(e, rescuable_error_type)
|
|
50
|
-
end
|
|
51
|
-
else
|
|
154
|
+
return super if rescuable_configs.empty?
|
|
155
|
+
|
|
156
|
+
begin
|
|
52
157
|
super
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
handle_rescued_error(e) || raise
|
|
53
160
|
end
|
|
54
161
|
end
|
|
55
162
|
|
|
56
|
-
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Handle a rescued error by finding matching config and processing it
|
|
57
166
|
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
167
|
+
# @param error [StandardError] The error to handle
|
|
168
|
+
# @return [Servus::Support::Response, nil] Response if error was handled, nil otherwise
|
|
169
|
+
def handle_rescued_error(error)
|
|
170
|
+
# Find the first matching config
|
|
171
|
+
config = rescuable_configs.find do |cfg|
|
|
172
|
+
cfg[:errors].any? { |error_class| error.is_a?(error_class) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
return nil unless config
|
|
176
|
+
|
|
177
|
+
if config[:handler]
|
|
178
|
+
# Use the block handler with BlockContext
|
|
179
|
+
block_context_result(error, config)
|
|
180
|
+
else
|
|
181
|
+
# Use the default handling
|
|
182
|
+
handle_failure(error, config[:error_type])
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Instantiates a block context to handle a rescued error
|
|
187
|
+
#
|
|
188
|
+
# @param error [StandardError] the caught exception
|
|
189
|
+
# @param config [Hash] The rescue config for the current error
|
|
190
|
+
#
|
|
191
|
+
# @api private
|
|
192
|
+
def block_context_result(error, config)
|
|
193
|
+
context = BlockContext.new
|
|
194
|
+
context.instance_exec(error, &config[:handler])
|
|
195
|
+
context.result
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Creates a failure response from a rescued exception.
|
|
199
|
+
#
|
|
200
|
+
# Converts the caught exception into a ServiceError of the specified type,
|
|
201
|
+
# preserving the original exception information in the error message.
|
|
202
|
+
#
|
|
203
|
+
# @param error [StandardError] the caught exception
|
|
204
|
+
# @param type [Class] ServiceError subclass to wrap the exception in
|
|
205
|
+
# @return [Servus::Support::Response] failure response with the wrapped error
|
|
60
206
|
#
|
|
61
|
-
# @
|
|
62
|
-
# @param [Class] type The error type
|
|
63
|
-
# @return [Servus::Support::Response] The failure response
|
|
207
|
+
# @api private
|
|
64
208
|
def handle_failure(error, type)
|
|
65
209
|
error = type.new(template_error_message(error))
|
|
66
210
|
Response.new(false, nil, error)
|
|
67
211
|
end
|
|
68
212
|
|
|
69
|
-
#
|
|
213
|
+
# Formats the exception message for the ServiceError.
|
|
214
|
+
#
|
|
215
|
+
# Creates a message that includes both the exception class and its original message,
|
|
216
|
+
# providing context about what actually failed.
|
|
217
|
+
#
|
|
218
|
+
# @param error [StandardError] the caught exception
|
|
219
|
+
# @return [String] formatted error message in the format "[ExceptionClass]: message"
|
|
220
|
+
#
|
|
221
|
+
# @example
|
|
222
|
+
# template_error_message(Net::HTTPError.new("Connection timeout"))
|
|
223
|
+
# # => "[Net::HTTPError]: Connection timeout"
|
|
70
224
|
#
|
|
71
|
-
# @
|
|
72
|
-
# @return [String] The formatted error message
|
|
225
|
+
# @api private
|
|
73
226
|
def template_error_message(error)
|
|
74
227
|
"[#{error.class}]: #{error.message}"
|
|
75
228
|
end
|
|
@@ -2,7 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
module Servus
|
|
4
4
|
module Support
|
|
5
|
-
#
|
|
5
|
+
# Encapsulates the result of a service execution.
|
|
6
|
+
#
|
|
7
|
+
# Response objects are returned by all service calls and contain either
|
|
8
|
+
# successful data or an error, never both. Use {#success?} to determine
|
|
9
|
+
# which path to take when handling results.
|
|
10
|
+
#
|
|
11
|
+
# @example Handling a successful response
|
|
12
|
+
# result = MyService.call(user_id: 123)
|
|
13
|
+
# if result.success?
|
|
14
|
+
# puts "Data: #{result.data}"
|
|
15
|
+
# puts "Error: #{result.error}" # => nil
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Handling a failed response
|
|
19
|
+
# result = MyService.call(user_id: -1)
|
|
20
|
+
# unless result.success?
|
|
21
|
+
# puts "Error: #{result.error.message}"
|
|
22
|
+
# puts "Data: #{result.data}" # => nil
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @example Pattern matching in controllers
|
|
26
|
+
# result = MyService.call(params)
|
|
27
|
+
# if result.success?
|
|
28
|
+
# render json: result.data, status: :ok
|
|
29
|
+
# else
|
|
30
|
+
# render json: result.error.message, status: :unprocessable_entity
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @see Servus::Base#success
|
|
34
|
+
# @see Servus::Base#failure
|
|
6
35
|
class Response
|
|
7
36
|
# [Object] The data returned by the service
|
|
8
37
|
attr_reader :data
|
|
@@ -10,20 +39,33 @@ module Servus
|
|
|
10
39
|
# [Servus::Support::Errors::ServiceError] The error returned by the service
|
|
11
40
|
attr_reader :error
|
|
12
41
|
|
|
13
|
-
#
|
|
42
|
+
# Creates a new response object.
|
|
14
43
|
#
|
|
15
|
-
# @
|
|
16
|
-
#
|
|
17
|
-
#
|
|
44
|
+
# @note This is typically called by {Servus::Base#success} or {Servus::Base#failure}
|
|
45
|
+
# rather than being instantiated directly.
|
|
46
|
+
#
|
|
47
|
+
# @param success [Boolean] true for successful responses, false for failures
|
|
48
|
+
# @param data [Object, nil] the result data (nil for failures)
|
|
49
|
+
# @param error [Servus::Support::Errors::ServiceError, nil] the error (nil for successes)
|
|
50
|
+
#
|
|
51
|
+
# @api private
|
|
18
52
|
def initialize(success, data, error)
|
|
19
53
|
@success = success
|
|
20
54
|
@data = data
|
|
21
55
|
@error = error
|
|
22
56
|
end
|
|
23
57
|
|
|
24
|
-
#
|
|
58
|
+
# Checks if the service execution was successful.
|
|
59
|
+
#
|
|
60
|
+
# @return [Boolean] true if the service succeeded, false if it failed
|
|
25
61
|
#
|
|
26
|
-
# @
|
|
62
|
+
# @example
|
|
63
|
+
# result = MyService.call(params)
|
|
64
|
+
# if result.success?
|
|
65
|
+
# # Handle success - result.data is available
|
|
66
|
+
# else
|
|
67
|
+
# # Handle failure - result.error is available
|
|
68
|
+
# end
|
|
27
69
|
def success?
|
|
28
70
|
@success
|
|
29
71
|
end
|
|
@@ -2,12 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
module Servus
|
|
4
4
|
module Support
|
|
5
|
-
#
|
|
5
|
+
# Handles JSON Schema validation for service arguments and results.
|
|
6
|
+
#
|
|
7
|
+
# The Validator class provides automatic validation of service inputs and outputs
|
|
8
|
+
# against JSON Schema definitions. Schemas can be defined as inline constants
|
|
9
|
+
# (ARGUMENTS_SCHEMA, RESULT_SCHEMA) or as external JSON files.
|
|
10
|
+
#
|
|
11
|
+
# @example Inline schema validation
|
|
12
|
+
# class MyService < Servus::Base
|
|
13
|
+
# ARGUMENTS_SCHEMA = {
|
|
14
|
+
# type: "object",
|
|
15
|
+
# required: ["user_id"],
|
|
16
|
+
# properties: {
|
|
17
|
+
# user_id: { type: "integer" }
|
|
18
|
+
# }
|
|
19
|
+
# }
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @example File-based schema validation
|
|
23
|
+
# # app/schemas/services/my_service/arguments.json
|
|
24
|
+
# # { "type": "object", "required": ["user_id"], ... }
|
|
25
|
+
#
|
|
26
|
+
# @see https://json-schema.org/specification.html
|
|
6
27
|
class Validator
|
|
7
|
-
#
|
|
28
|
+
# @api private
|
|
8
29
|
@schema_cache = {}
|
|
9
30
|
|
|
10
|
-
#
|
|
31
|
+
# Validates service arguments against the ARGUMENTS_SCHEMA.
|
|
32
|
+
#
|
|
33
|
+
# Checks arguments against either an inline ARGUMENTS_SCHEMA constant or
|
|
34
|
+
# a file-based schema at app/schemas/services/namespace/arguments.json.
|
|
35
|
+
# Validation is skipped if no schema is defined.
|
|
36
|
+
#
|
|
37
|
+
# @param service_class [Class] the service class being validated
|
|
38
|
+
# @param args [Hash] keyword arguments passed to the service
|
|
39
|
+
# @return [Boolean] true if validation passes
|
|
40
|
+
# @raise [Servus::Support::Errors::ValidationError] if arguments fail validation
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# Validator.validate_arguments!(MyService, { user_id: 123 })
|
|
44
|
+
#
|
|
45
|
+
# @api private
|
|
11
46
|
def self.validate_arguments!(service_class, args)
|
|
12
47
|
schema = load_schema(service_class, 'arguments')
|
|
13
48
|
return true unless schema # Skip validation if no schema exists
|
|
@@ -23,7 +58,21 @@ module Servus
|
|
|
23
58
|
true
|
|
24
59
|
end
|
|
25
60
|
|
|
26
|
-
#
|
|
61
|
+
# Validates service result data against the RESULT_SCHEMA.
|
|
62
|
+
#
|
|
63
|
+
# Checks the result.data against either an inline RESULT_SCHEMA constant or
|
|
64
|
+
# a file-based schema at app/schemas/services/namespace/result.json.
|
|
65
|
+
# Only validates successful responses; failures are skipped.
|
|
66
|
+
#
|
|
67
|
+
# @param service_class [Class] the service class being validated
|
|
68
|
+
# @param result [Servus::Support::Response] the response object to validate
|
|
69
|
+
# @return [Servus::Support::Response] the original result if validation passes
|
|
70
|
+
# @raise [Servus::Support::Errors::ValidationError] if result data fails validation
|
|
71
|
+
#
|
|
72
|
+
# @example
|
|
73
|
+
# Validator.validate_result!(MyService, response)
|
|
74
|
+
#
|
|
75
|
+
# @api private
|
|
27
76
|
def self.validate_result!(service_class, result)
|
|
28
77
|
return result unless result.success?
|
|
29
78
|
|
|
@@ -41,7 +90,21 @@ module Servus
|
|
|
41
90
|
result
|
|
42
91
|
end
|
|
43
92
|
|
|
44
|
-
#
|
|
93
|
+
# Loads and caches a schema for a service.
|
|
94
|
+
#
|
|
95
|
+
# Implements a three-tier lookup strategy:
|
|
96
|
+
# 1. Check for schema defined via DSL method (service_class.arguments_schema/result_schema)
|
|
97
|
+
# 2. Check for inline constant (ARGUMENTS_SCHEMA or RESULT_SCHEMA)
|
|
98
|
+
# 3. Fall back to JSON file in app/schemas/services/namespace/type.json
|
|
99
|
+
#
|
|
100
|
+
# Schemas are cached after first load for performance.
|
|
101
|
+
#
|
|
102
|
+
# @param service_class [Class] the service class
|
|
103
|
+
# @param type [String] schema type ("arguments" or "result")
|
|
104
|
+
# @return [Hash, nil] the schema hash, or nil if no schema found
|
|
105
|
+
#
|
|
106
|
+
# @api private
|
|
107
|
+
# rubocop:disable Metrics/MethodLength
|
|
45
108
|
def self.load_schema(service_class, type)
|
|
46
109
|
# Get service path based on class name (e.g., "process_payment" from "Servus::ProcessPayment::Service")
|
|
47
110
|
service_namespace = parse_service_namespace(service_class)
|
|
@@ -50,45 +113,83 @@ module Servus
|
|
|
50
113
|
# Return from cache if available
|
|
51
114
|
return @schema_cache[schema_path] if @schema_cache.key?(schema_path)
|
|
52
115
|
|
|
116
|
+
# Check for DSL-defined schema first
|
|
117
|
+
dsl_schema = if type == 'arguments'
|
|
118
|
+
service_class.arguments_schema
|
|
119
|
+
else
|
|
120
|
+
service_class.result_schema
|
|
121
|
+
end
|
|
122
|
+
|
|
53
123
|
inline_schema_constant_name = "#{service_class}::#{type.upcase}_SCHEMA"
|
|
54
124
|
inline_schema_constant = if Object.const_defined?(inline_schema_constant_name)
|
|
55
125
|
Object.const_get(inline_schema_constant_name)
|
|
56
126
|
end
|
|
57
127
|
|
|
58
|
-
@schema_cache[schema_path] = fetch_schema_from_sources(inline_schema_constant, schema_path)
|
|
128
|
+
@schema_cache[schema_path] = fetch_schema_from_sources(dsl_schema, inline_schema_constant, schema_path)
|
|
59
129
|
@schema_cache[schema_path]
|
|
60
130
|
end
|
|
131
|
+
# rubocop:enable Metrics/MethodLength
|
|
61
132
|
|
|
62
|
-
#
|
|
133
|
+
# Clears the schema cache.
|
|
134
|
+
#
|
|
135
|
+
# Useful in development when schema files are modified, or in tests
|
|
136
|
+
# to ensure fresh schema loading between test cases.
|
|
137
|
+
#
|
|
138
|
+
# @return [Hash] empty hash
|
|
139
|
+
#
|
|
140
|
+
# @example In a test suite
|
|
141
|
+
# before(:each) do
|
|
142
|
+
# Servus::Support::Validator.clear_cache!
|
|
143
|
+
# end
|
|
63
144
|
def self.clear_cache!
|
|
64
145
|
@schema_cache = {}
|
|
65
146
|
end
|
|
66
147
|
|
|
67
|
-
# Returns the schema cache
|
|
148
|
+
# Returns the current schema cache.
|
|
149
|
+
#
|
|
150
|
+
# @return [Hash] cache mapping schema paths to loaded schemas
|
|
151
|
+
# @api private
|
|
68
152
|
def self.cache
|
|
69
153
|
@schema_cache
|
|
70
154
|
end
|
|
71
155
|
|
|
72
|
-
# Fetches
|
|
156
|
+
# Fetches schema from DSL, inline constant, or file.
|
|
157
|
+
#
|
|
158
|
+
# Implements the schema resolution precedence:
|
|
159
|
+
# 1. DSL-defined schema (if provided)
|
|
160
|
+
# 2. Inline constant (if provided)
|
|
161
|
+
# 3. File at schema_path (if exists)
|
|
162
|
+
# 4. nil (no schema found)
|
|
73
163
|
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
164
|
+
# @param dsl_schema [Hash, nil] schema from DSL method (e.g., schema arguments: Hash)
|
|
165
|
+
# @param inline_schema_constant [Hash, nil] inline schema constant (e.g., ARGUMENTS_SCHEMA)
|
|
166
|
+
# @param schema_path [String] file path to external schema JSON
|
|
167
|
+
# @return [Hash, nil] schema with indifferent access, or nil if not found
|
|
76
168
|
#
|
|
77
|
-
# @
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
169
|
+
# @api private
|
|
170
|
+
def self.fetch_schema_from_sources(dsl_schema, inline_schema_constant, schema_path)
|
|
171
|
+
if dsl_schema
|
|
172
|
+
dsl_schema.with_indifferent_access
|
|
173
|
+
elsif inline_schema_constant
|
|
82
174
|
inline_schema_constant.with_indifferent_access
|
|
83
175
|
elsif File.exist?(schema_path)
|
|
84
176
|
JSON.load_file(schema_path).with_indifferent_access
|
|
85
177
|
end
|
|
86
178
|
end
|
|
87
179
|
|
|
88
|
-
#
|
|
180
|
+
# Converts service class name to file path namespace.
|
|
181
|
+
#
|
|
182
|
+
# Transforms a class name like "Services::ProcessPayment::Service" into
|
|
183
|
+
# "services/process_payment" for locating schema files.
|
|
184
|
+
#
|
|
185
|
+
# @param service_class [Class] the service class
|
|
186
|
+
# @return [String] underscored namespace path
|
|
187
|
+
#
|
|
188
|
+
# @example
|
|
189
|
+
# parse_service_namespace(Services::ProcessPayment::Service)
|
|
190
|
+
# # => "services/process_payment"
|
|
89
191
|
#
|
|
90
|
-
# @
|
|
91
|
-
# @return [String] the service namespace
|
|
192
|
+
# @api private
|
|
92
193
|
def self.parse_service_namespace(service_class)
|
|
93
194
|
service_class.name.split('::')[..-2].map do |s|
|
|
94
195
|
s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'example_extractor'
|
|
4
|
+
|
|
5
|
+
module Servus
|
|
6
|
+
module Testing
|
|
7
|
+
# Provides helper methods for extracting example values from service schemas.
|
|
8
|
+
#
|
|
9
|
+
# This module is designed to be included in test files (RSpec, Minitest, etc.)
|
|
10
|
+
# to provide convenient access to schema example values. It's particularly useful
|
|
11
|
+
# for generating test fixtures without manually maintaining separate factory files.
|
|
12
|
+
#
|
|
13
|
+
# The `servus_` prefix on method names prevents naming collisions with other
|
|
14
|
+
# testing libraries and makes it clear these are Servus-specific helpers.
|
|
15
|
+
#
|
|
16
|
+
# @example Include in RSpec
|
|
17
|
+
# # spec/spec_helper.rb
|
|
18
|
+
# require 'servus/testing/example_builders'
|
|
19
|
+
#
|
|
20
|
+
# RSpec.configure do |config|
|
|
21
|
+
# config.include Servus::Testing::ExampleBuilders
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example Include in Rails console (development)
|
|
25
|
+
# # config/environments/development.rb
|
|
26
|
+
# config.to_prepare do
|
|
27
|
+
# require 'servus/testing/example_builders'
|
|
28
|
+
#
|
|
29
|
+
# if defined?(Rails::Console)
|
|
30
|
+
# include Servus::Testing::ExampleBuilders
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# @example Use in tests
|
|
35
|
+
# RSpec.describe ProcessPayment::Service do
|
|
36
|
+
# it 'processes payment successfully' do
|
|
37
|
+
# args = servus_arguments_example(ProcessPayment::Service, amount: 50.0)
|
|
38
|
+
# result = ProcessPayment::Service.call(**args)
|
|
39
|
+
#
|
|
40
|
+
# expect(result).to be_success
|
|
41
|
+
# end
|
|
42
|
+
# end
|
|
43
|
+
module ExampleBuilders
|
|
44
|
+
# Extracts example argument values from a service's schema.
|
|
45
|
+
#
|
|
46
|
+
# Looks for `example` or `examples` keywords in the service's arguments schema
|
|
47
|
+
# and returns them as a hash ready to be passed to the service's `.call` method.
|
|
48
|
+
#
|
|
49
|
+
# @param service_class [Class] The service class to extract examples from
|
|
50
|
+
# @param overrides [Hash] Optional values to override the schema examples
|
|
51
|
+
# @return [Hash<Symbol, Object>] Hash of example argument values with symbolized keys
|
|
52
|
+
#
|
|
53
|
+
# @example Basic usage
|
|
54
|
+
# args = servus_arguments_example(ProcessPayment::Service)
|
|
55
|
+
# # => { user_id: 123, amount: 100.0, currency: 'USD' }
|
|
56
|
+
#
|
|
57
|
+
# result = ProcessPayment::Service.call(**args)
|
|
58
|
+
#
|
|
59
|
+
# @example With overrides
|
|
60
|
+
# args = servus_arguments_example(ProcessPayment::Service, amount: 50.0, currency: 'EUR')
|
|
61
|
+
# # => { user_id: 123, amount: 50.0, currency: 'EUR' }
|
|
62
|
+
#
|
|
63
|
+
# @example In RSpec tests
|
|
64
|
+
# it 'processes different currencies' do
|
|
65
|
+
# %w[USD EUR GBP].each do |currency|
|
|
66
|
+
# result = ProcessPayment::Service.call(
|
|
67
|
+
# **servus_arguments_example(ProcessPayment::Service, currency: currency)
|
|
68
|
+
# )
|
|
69
|
+
# expect(result).to be_success
|
|
70
|
+
# end
|
|
71
|
+
# end
|
|
72
|
+
#
|
|
73
|
+
# @note Override keys can be strings or symbols; they'll be converted to symbols
|
|
74
|
+
# @note Returns empty hash if service has no arguments schema defined
|
|
75
|
+
def servus_arguments_example(service_class, overrides = {})
|
|
76
|
+
extract_example_from(service_class, :arguments, overrides)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Extracts example result values from a service's schema.
|
|
80
|
+
#
|
|
81
|
+
# Looks for `example` or `examples` keywords in the service's result schema
|
|
82
|
+
# and returns them as a hash. Useful for validating service response structure
|
|
83
|
+
# and expected data shapes in tests.
|
|
84
|
+
#
|
|
85
|
+
# @param service_class [Class] The service class to extract examples from
|
|
86
|
+
# @param overrides [Hash] Optional values to override the schema examples
|
|
87
|
+
# @return [Servus::Support::Response] Response object with example result data
|
|
88
|
+
#
|
|
89
|
+
# @example Basic usage
|
|
90
|
+
# expected = servus_result_example(ProcessPayment::Service)
|
|
91
|
+
# # => Servus::Support::Response with data:
|
|
92
|
+
# # { transaction_id: 'txn_abc123', status: 'approved', amount_charged: 100.0 }
|
|
93
|
+
#
|
|
94
|
+
# @example Validate result structure
|
|
95
|
+
# result = ProcessPayment::Service.call(**servus_arguments_example(ProcessPayment::Service))
|
|
96
|
+
#
|
|
97
|
+
# expect(result.data).to match(
|
|
98
|
+
# hash_including(servus_result_example(ProcessPayment::Service).data)
|
|
99
|
+
# )
|
|
100
|
+
#
|
|
101
|
+
# @example Check result has expected keys
|
|
102
|
+
# result = ProcessPayment::Service.call(**args)
|
|
103
|
+
# expected_keys = servus_result_example(ProcessPayment::Service).data.keys
|
|
104
|
+
#
|
|
105
|
+
# expect(result.data.keys).to match_array(expected_keys)
|
|
106
|
+
#
|
|
107
|
+
# @example With overrides
|
|
108
|
+
# expected = servus_result_example(ProcessPayment::Service, status: 'pending').data
|
|
109
|
+
# # => { transaction_id: 'txn_abc123', status: 'pending', amount_charged: 100.0 }
|
|
110
|
+
#
|
|
111
|
+
# @note Override keys can be strings or symbols; they'll be converted to symbols
|
|
112
|
+
# @note Returns empty hash if service has no result schema defined
|
|
113
|
+
def servus_result_example(service_class, overrides = {})
|
|
114
|
+
example = extract_example_from(service_class, :result, overrides)
|
|
115
|
+
# Wrap in a successful Response object
|
|
116
|
+
Servus::Support::Response.new(true, example, nil)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Helper method to extract and merge examples from schema
|
|
122
|
+
#
|
|
123
|
+
# @param service_class [Class] The service class to extract examples from
|
|
124
|
+
# @param schema_type [Symbol] The type of schema (:arguments or :result)
|
|
125
|
+
# @param overrides [Hash] Optional values to override the schema examples
|
|
126
|
+
# @return [Hash<Symbol, Object>] Hash of example values with symbolized keys
|
|
127
|
+
def extract_example_from(service_class, schema_type, overrides = {})
|
|
128
|
+
examples = ExampleExtractor.extract(service_class, schema_type)
|
|
129
|
+
examples.deep_merge(overrides.deep_symbolize_keys)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|