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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +8 -1
  4. data/IDEAS.md +5 -0
  5. data/READme.md +147 -42
  6. data/Rakefile +33 -0
  7. data/builds/servus-0.1.2.gem +0 -0
  8. data/builds/servus-0.1.3.gem +0 -0
  9. data/builds/servus-0.1.4.gem +0 -0
  10. data/docs/core/1_overview.md +77 -0
  11. data/docs/core/2_architecture.md +92 -0
  12. data/docs/core/3_service_objects.md +121 -0
  13. data/docs/features/1_schema_validation.md +119 -0
  14. data/docs/features/2_error_handling.md +121 -0
  15. data/docs/features/3_async_execution.md +81 -0
  16. data/docs/features/4_logging.md +64 -0
  17. data/docs/guides/1_common_patterns.md +90 -0
  18. data/docs/guides/2_migration_guide.md +175 -0
  19. data/docs/integration/1_configuration.md +51 -0
  20. data/docs/integration/2_testing.md +164 -0
  21. data/docs/integration/3_rails_integration.md +99 -0
  22. data/docs/yard/Servus/Base.html +1645 -0
  23. data/docs/yard/Servus/Config.html +582 -0
  24. data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
  25. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
  26. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
  27. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
  28. data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
  29. data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
  30. data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
  31. data/docs/yard/Servus/Extensions/Async.html +141 -0
  32. data/docs/yard/Servus/Extensions.html +117 -0
  33. data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
  34. data/docs/yard/Servus/Generators.html +115 -0
  35. data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
  36. data/docs/yard/Servus/Helpers.html +115 -0
  37. data/docs/yard/Servus/Railtie.html +134 -0
  38. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
  39. data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
  40. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
  41. data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
  42. data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
  43. data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
  44. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
  45. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
  46. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
  47. data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
  48. data/docs/yard/Servus/Support/Errors.html +140 -0
  49. data/docs/yard/Servus/Support/Logger.html +856 -0
  50. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
  51. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
  52. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
  53. data/docs/yard/Servus/Support/Rescuer.html +267 -0
  54. data/docs/yard/Servus/Support/Response.html +574 -0
  55. data/docs/yard/Servus/Support/Validator.html +1150 -0
  56. data/docs/yard/Servus/Support.html +119 -0
  57. data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
  58. data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
  59. data/docs/yard/Servus/Testing.html +142 -0
  60. data/docs/yard/Servus.html +343 -0
  61. data/docs/yard/_index.html +535 -0
  62. data/docs/yard/class_list.html +54 -0
  63. data/docs/yard/css/common.css +1 -0
  64. data/docs/yard/css/full_list.css +58 -0
  65. data/docs/yard/css/style.css +503 -0
  66. data/docs/yard/file.1_common_patterns.html +154 -0
  67. data/docs/yard/file.1_configuration.html +115 -0
  68. data/docs/yard/file.1_overview.html +142 -0
  69. data/docs/yard/file.1_schema_validation.html +188 -0
  70. data/docs/yard/file.2_architecture.html +157 -0
  71. data/docs/yard/file.2_error_handling.html +190 -0
  72. data/docs/yard/file.2_migration_guide.html +242 -0
  73. data/docs/yard/file.2_testing.html +227 -0
  74. data/docs/yard/file.3_async_execution.html +145 -0
  75. data/docs/yard/file.3_rails_integration.html +160 -0
  76. data/docs/yard/file.3_service_objects.html +191 -0
  77. data/docs/yard/file.4_logging.html +135 -0
  78. data/docs/yard/file.ErrorHandling.html +190 -0
  79. data/docs/yard/file.READme.html +674 -0
  80. data/docs/yard/file.architecture.html +157 -0
  81. data/docs/yard/file.async_execution.html +145 -0
  82. data/docs/yard/file.common_patterns.html +154 -0
  83. data/docs/yard/file.configuration.html +115 -0
  84. data/docs/yard/file.error_handling.html +190 -0
  85. data/docs/yard/file.logging.html +135 -0
  86. data/docs/yard/file.migration_guide.html +242 -0
  87. data/docs/yard/file.overview.html +142 -0
  88. data/docs/yard/file.rails_integration.html +160 -0
  89. data/docs/yard/file.schema_validation.html +188 -0
  90. data/docs/yard/file.service_objects.html +191 -0
  91. data/docs/yard/file.testing.html +227 -0
  92. data/docs/yard/file_list.html +119 -0
  93. data/docs/yard/frames.html +22 -0
  94. data/docs/yard/index.html +674 -0
  95. data/docs/yard/js/app.js +344 -0
  96. data/docs/yard/js/full_list.js +242 -0
  97. data/docs/yard/js/jquery.js +4 -0
  98. data/docs/yard/method_list.html +542 -0
  99. data/docs/yard/top-level-namespace.html +110 -0
  100. data/lib/generators/servus/service/service_generator.rb +64 -1
  101. data/lib/generators/servus/service/templates/service.rb.erb +1 -1
  102. data/lib/servus/base.rb +258 -57
  103. data/lib/servus/config.rb +58 -12
  104. data/lib/servus/extensions/async/call.rb +50 -18
  105. data/lib/servus/extensions/async/errors.rb +23 -3
  106. data/lib/servus/extensions/async/ext.rb +10 -2
  107. data/lib/servus/extensions/async/job.rb +32 -11
  108. data/lib/servus/helpers/controller_helpers.rb +73 -37
  109. data/lib/servus/support/errors.rb +135 -45
  110. data/lib/servus/support/rescuer.rb +189 -36
  111. data/lib/servus/support/response.rb +49 -7
  112. data/lib/servus/support/validator.rb +120 -19
  113. data/lib/servus/testing/example_builders.rb +133 -0
  114. data/lib/servus/testing/example_extractor.rb +309 -0
  115. data/lib/servus/testing.rb +17 -0
  116. data/lib/servus/version.rb +1 -1
  117. metadata +118 -19
@@ -2,74 +2,227 @@
2
2
 
3
3
  module Servus
4
4
  module Support
5
- # Module that rescues the call method from errors
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
- # Includes the rescuer module into the base class
22
+ # Sets up error rescue functionality when included.
8
23
  #
9
- # @param base [Class] The base class to include the rescuer module into
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 :rescuable_errors, default: []
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
- # Rescues the call method from errors
87
+ # Configures automatic error handling for the service.
20
88
  #
21
- # By configuring error classes in the rescue_from method, the call method will rescue from those errors
22
- # and return a failure response with a ServiceError and formatted error message. This prevents the need to
23
- # to have excessive rescue blocks in the call method.
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 SomeError, type: Servus::Support::Errors::ServiceError
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 [Error] errors One or more errors to rescue from (variadic)
31
- # @param [Error] use The error to be used (optional, defaults to Servus::Support::Errors::ServiceError)
32
- def rescue_from(*errors, use: Servus::Support::Errors::ServiceError)
33
- self.rescuable_errors = errors
34
- self.rescuable_error_type = use
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
- # Module that overrides the call method to rescue from errors
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
- # Overrides the call method to rescue from errors
144
+ # Wraps the service call with automatic error rescue.
41
145
  #
42
- # @param args [Hash] The arguments passed to the call method
43
- # @return [Servus::Support::Response] The result of the call method
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 rescuable_errors.any?
46
- begin
47
- super
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
- # Returns a failure response with a ServiceError and formatted error message
163
+ private
164
+
165
+ # Handle a rescued error by finding matching config and processing it
57
166
  #
58
- # The `failure` method is an instance method of the base class, so it can't be called from this module which
59
- # is rescuing the call method.
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
- # @param [Error] error The error to be used
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
- # Templates the error message
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
- # @param [Error] error The error to be used
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
- # Response class for service results
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
- # Initializes a new response
42
+ # Creates a new response object.
14
43
  #
15
- # @param success [Boolean] Whether the response was successful
16
- # @param data [Object] The data returned by the service
17
- # @param error [Servus::Support::Errors::ServiceError] The error returned by the service
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
- # Returns whether the response was successful
58
+ # Checks if the service execution was successful.
59
+ #
60
+ # @return [Boolean] true if the service succeeded, false if it failed
25
61
  #
26
- # @return [Boolean] Whether the response was successful
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
- # Validates arguments and results
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
- # Class-level schema cache
28
+ # @api private
8
29
  @schema_cache = {}
9
30
 
10
- # Validate service arguments against schema
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
- # Validate service result against schema
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
- # Load schema from file with caching
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
- # Clear the schema cache (useful for testing or development)
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 the schema from the sources
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
- # This method checks if the schema is defined as an inline constant or if it exists as a file. The
75
- # schema is then symbolized and returned. If the schema is not found, nil is returned.
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
- # @param inline_schema_constant [Hash, String] the inline schema constant to process
78
- # @param schema_path [String] the path to the schema file
79
- # @return [Hash] the processed inline schema constant
80
- def self.fetch_schema_from_sources(inline_schema_constant, schema_path)
81
- if inline_schema_constant
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
- # Parses the service namespace from the service class name
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
- # @param service_class [Class] the service class to parse
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