servus 0.1.3 → 0.1.5

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 (139) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/check-docs.md +1 -0
  3. data/.claude/commands/consistency-check.md +1 -0
  4. data/.claude/commands/fine-tooth-comb.md +1 -0
  5. data/.claude/commands/red-green-refactor.md +5 -0
  6. data/.claude/settings.json +15 -0
  7. data/.rubocop.yml +18 -2
  8. data/.yardopts +6 -0
  9. data/CHANGELOG.md +47 -0
  10. data/CLAUDE.md +10 -0
  11. data/IDEAS.md +5 -0
  12. data/READme.md +300 -47
  13. data/Rakefile +33 -0
  14. data/builds/servus-0.1.3.gem +0 -0
  15. data/builds/servus-0.1.4.gem +0 -0
  16. data/builds/servus-0.1.5.gem +0 -0
  17. data/docs/core/1_overview.md +77 -0
  18. data/docs/core/2_architecture.md +120 -0
  19. data/docs/core/3_service_objects.md +121 -0
  20. data/docs/current_focus.md +569 -0
  21. data/docs/features/1_schema_validation.md +119 -0
  22. data/docs/features/2_error_handling.md +121 -0
  23. data/docs/features/3_async_execution.md +81 -0
  24. data/docs/features/4_logging.md +64 -0
  25. data/docs/features/5_event_bus.md +244 -0
  26. data/docs/guides/1_common_patterns.md +90 -0
  27. data/docs/guides/2_migration_guide.md +175 -0
  28. data/docs/integration/1_configuration.md +104 -0
  29. data/docs/integration/2_testing.md +287 -0
  30. data/docs/integration/3_rails_integration.md +99 -0
  31. data/docs/yard/Servus/Base.html +1645 -0
  32. data/docs/yard/Servus/Config.html +582 -0
  33. data/docs/yard/Servus/Extensions/Async/Call.html +400 -0
  34. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +140 -0
  35. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +154 -0
  36. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +154 -0
  37. data/docs/yard/Servus/Extensions/Async/Errors.html +128 -0
  38. data/docs/yard/Servus/Extensions/Async/Ext.html +119 -0
  39. data/docs/yard/Servus/Extensions/Async/Job.html +310 -0
  40. data/docs/yard/Servus/Extensions/Async.html +141 -0
  41. data/docs/yard/Servus/Extensions.html +117 -0
  42. data/docs/yard/Servus/Generators/ServiceGenerator.html +261 -0
  43. data/docs/yard/Servus/Generators.html +115 -0
  44. data/docs/yard/Servus/Helpers/ControllerHelpers.html +457 -0
  45. data/docs/yard/Servus/Helpers.html +115 -0
  46. data/docs/yard/Servus/Railtie.html +134 -0
  47. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +287 -0
  48. data/docs/yard/Servus/Support/Errors/BadRequestError.html +283 -0
  49. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +284 -0
  50. data/docs/yard/Servus/Support/Errors/InternalServerError.html +283 -0
  51. data/docs/yard/Servus/Support/Errors/NotFoundError.html +284 -0
  52. data/docs/yard/Servus/Support/Errors/ServiceError.html +489 -0
  53. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +290 -0
  54. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +200 -0
  55. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +288 -0
  56. data/docs/yard/Servus/Support/Errors/ValidationError.html +200 -0
  57. data/docs/yard/Servus/Support/Errors.html +140 -0
  58. data/docs/yard/Servus/Support/Logger.html +856 -0
  59. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +585 -0
  60. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +257 -0
  61. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +343 -0
  62. data/docs/yard/Servus/Support/Rescuer.html +267 -0
  63. data/docs/yard/Servus/Support/Response.html +574 -0
  64. data/docs/yard/Servus/Support/Validator.html +1150 -0
  65. data/docs/yard/Servus/Support.html +119 -0
  66. data/docs/yard/Servus/Testing/ExampleBuilders.html +523 -0
  67. data/docs/yard/Servus/Testing/ExampleExtractor.html +578 -0
  68. data/docs/yard/Servus/Testing.html +142 -0
  69. data/docs/yard/Servus.html +343 -0
  70. data/docs/yard/_index.html +535 -0
  71. data/docs/yard/class_list.html +54 -0
  72. data/docs/yard/css/common.css +1 -0
  73. data/docs/yard/css/full_list.css +58 -0
  74. data/docs/yard/css/style.css +503 -0
  75. data/docs/yard/file.1_common_patterns.html +154 -0
  76. data/docs/yard/file.1_configuration.html +115 -0
  77. data/docs/yard/file.1_overview.html +142 -0
  78. data/docs/yard/file.1_schema_validation.html +188 -0
  79. data/docs/yard/file.2_architecture.html +157 -0
  80. data/docs/yard/file.2_error_handling.html +190 -0
  81. data/docs/yard/file.2_migration_guide.html +242 -0
  82. data/docs/yard/file.2_testing.html +227 -0
  83. data/docs/yard/file.3_async_execution.html +145 -0
  84. data/docs/yard/file.3_rails_integration.html +160 -0
  85. data/docs/yard/file.3_service_objects.html +191 -0
  86. data/docs/yard/file.4_logging.html +135 -0
  87. data/docs/yard/file.ErrorHandling.html +190 -0
  88. data/docs/yard/file.READme.html +674 -0
  89. data/docs/yard/file.architecture.html +157 -0
  90. data/docs/yard/file.async_execution.html +145 -0
  91. data/docs/yard/file.common_patterns.html +154 -0
  92. data/docs/yard/file.configuration.html +115 -0
  93. data/docs/yard/file.error_handling.html +190 -0
  94. data/docs/yard/file.logging.html +135 -0
  95. data/docs/yard/file.migration_guide.html +242 -0
  96. data/docs/yard/file.overview.html +142 -0
  97. data/docs/yard/file.rails_integration.html +160 -0
  98. data/docs/yard/file.schema_validation.html +188 -0
  99. data/docs/yard/file.service_objects.html +191 -0
  100. data/docs/yard/file.testing.html +227 -0
  101. data/docs/yard/file_list.html +119 -0
  102. data/docs/yard/frames.html +22 -0
  103. data/docs/yard/index.html +674 -0
  104. data/docs/yard/js/app.js +344 -0
  105. data/docs/yard/js/full_list.js +242 -0
  106. data/docs/yard/js/jquery.js +4 -0
  107. data/docs/yard/method_list.html +542 -0
  108. data/docs/yard/top-level-namespace.html +110 -0
  109. data/lib/generators/servus/event_handler/event_handler_generator.rb +59 -0
  110. data/lib/generators/servus/event_handler/templates/handler.rb.erb +86 -0
  111. data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +48 -0
  112. data/lib/generators/servus/service/service_generator.rb +68 -1
  113. data/lib/generators/servus/service/templates/arguments.json.erb +19 -10
  114. data/lib/generators/servus/service/templates/result.json.erb +8 -2
  115. data/lib/generators/servus/service/templates/service.rb.erb +102 -5
  116. data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
  117. data/lib/servus/base.rb +275 -58
  118. data/lib/servus/config.rb +83 -17
  119. data/lib/servus/event_handler.rb +275 -0
  120. data/lib/servus/events/bus.rb +137 -0
  121. data/lib/servus/events/emitter.rb +162 -0
  122. data/lib/servus/events/errors.rb +10 -0
  123. data/lib/servus/extensions/async/call.rb +50 -18
  124. data/lib/servus/extensions/async/errors.rb +23 -3
  125. data/lib/servus/extensions/async/ext.rb +10 -2
  126. data/lib/servus/extensions/async/job.rb +30 -9
  127. data/lib/servus/helpers/controller_helpers.rb +73 -37
  128. data/lib/servus/railtie.rb +16 -0
  129. data/lib/servus/support/errors.rb +135 -45
  130. data/lib/servus/support/rescuer.rb +189 -36
  131. data/lib/servus/support/response.rb +49 -7
  132. data/lib/servus/support/validator.rb +147 -19
  133. data/lib/servus/testing/example_builders.rb +133 -0
  134. data/lib/servus/testing/example_extractor.rb +309 -0
  135. data/lib/servus/testing/matchers.rb +88 -0
  136. data/lib/servus/testing.rb +19 -0
  137. data/lib/servus/version.rb +1 -1
  138. data/lib/servus.rb +6 -0
  139. metadata +135 -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,48 @@ module Servus
41
90
  result
42
91
  end
43
92
 
44
- # Load schema from file with caching
93
+ # Validates event payload against the handler's payload schema.
94
+ #
95
+ # @param handler_class [Class] the event handler class
96
+ # @param payload [Hash] the event payload to validate
97
+ # @return [Boolean] true if validation passes
98
+ # @raise [Servus::Support::Errors::ValidationError] if payload fails validation
99
+ #
100
+ #
101
+ # @example
102
+ # Validator.validate_event_payload!(MyEventHandler, { user_id: 123 })
103
+ #
104
+ # @api private
105
+ def self.validate_event_payload!(handler_class, payload)
106
+ schema = handler_class.payload_schema
107
+ return true unless schema
108
+
109
+ serialized_payload = payload.as_json
110
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_payload)
111
+
112
+ if validation_errors.any?
113
+ raise Servus::Support::Errors::ValidationError,
114
+ "Invalid payload for event :#{handler_class.event_name}: #{validation_errors.join(', ')}"
115
+ end
116
+
117
+ true
118
+ end
119
+
120
+ # Loads and caches a schema for a service.
121
+ #
122
+ # Implements a three-tier lookup strategy:
123
+ # 1. Check for schema defined via DSL method (service_class.arguments_schema/result_schema)
124
+ # 2. Check for inline constant (ARGUMENTS_SCHEMA or RESULT_SCHEMA)
125
+ # 3. Fall back to JSON file in app/schemas/services/namespace/type.json
126
+ #
127
+ # Schemas are cached after first load for performance.
128
+ #
129
+ # @param service_class [Class] the service class
130
+ # @param type [String] schema type ("arguments" or "result")
131
+ # @return [Hash, nil] the schema hash, or nil if no schema found
132
+ #
133
+ # @api private
134
+ # rubocop:disable Metrics/MethodLength
45
135
  def self.load_schema(service_class, type)
46
136
  # Get service path based on class name (e.g., "process_payment" from "Servus::ProcessPayment::Service")
47
137
  service_namespace = parse_service_namespace(service_class)
@@ -50,45 +140,83 @@ module Servus
50
140
  # Return from cache if available
51
141
  return @schema_cache[schema_path] if @schema_cache.key?(schema_path)
52
142
 
143
+ # Check for DSL-defined schema first
144
+ dsl_schema = if type == 'arguments'
145
+ service_class.arguments_schema
146
+ else
147
+ service_class.result_schema
148
+ end
149
+
53
150
  inline_schema_constant_name = "#{service_class}::#{type.upcase}_SCHEMA"
54
151
  inline_schema_constant = if Object.const_defined?(inline_schema_constant_name)
55
152
  Object.const_get(inline_schema_constant_name)
56
153
  end
57
154
 
58
- @schema_cache[schema_path] = fetch_schema_from_sources(inline_schema_constant, schema_path)
155
+ @schema_cache[schema_path] = fetch_schema_from_sources(dsl_schema, inline_schema_constant, schema_path)
59
156
  @schema_cache[schema_path]
60
157
  end
158
+ # rubocop:enable Metrics/MethodLength
61
159
 
62
- # Clear the schema cache (useful for testing or development)
160
+ # Clears the schema cache.
161
+ #
162
+ # Useful in development when schema files are modified, or in tests
163
+ # to ensure fresh schema loading between test cases.
164
+ #
165
+ # @return [Hash] empty hash
166
+ #
167
+ # @example In a test suite
168
+ # before(:each) do
169
+ # Servus::Support::Validator.clear_cache!
170
+ # end
63
171
  def self.clear_cache!
64
172
  @schema_cache = {}
65
173
  end
66
174
 
67
- # Returns the schema cache
175
+ # Returns the current schema cache.
176
+ #
177
+ # @return [Hash] cache mapping schema paths to loaded schemas
178
+ # @api private
68
179
  def self.cache
69
180
  @schema_cache
70
181
  end
71
182
 
72
- # Fetches the schema from the sources
183
+ # Fetches schema from DSL, inline constant, or file.
73
184
  #
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.
185
+ # Implements the schema resolution precedence:
186
+ # 1. DSL-defined schema (if provided)
187
+ # 2. Inline constant (if provided)
188
+ # 3. File at schema_path (if exists)
189
+ # 4. nil (no schema found)
76
190
  #
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
191
+ # @param dsl_schema [Hash, nil] schema from DSL method (e.g., schema arguments: Hash)
192
+ # @param inline_schema_constant [Hash, nil] inline schema constant (e.g., ARGUMENTS_SCHEMA)
193
+ # @param schema_path [String] file path to external schema JSON
194
+ # @return [Hash, nil] schema with indifferent access, or nil if not found
195
+ #
196
+ # @api private
197
+ def self.fetch_schema_from_sources(dsl_schema, inline_schema_constant, schema_path)
198
+ if dsl_schema
199
+ dsl_schema.with_indifferent_access
200
+ elsif inline_schema_constant
82
201
  inline_schema_constant.with_indifferent_access
83
202
  elsif File.exist?(schema_path)
84
203
  JSON.load_file(schema_path).with_indifferent_access
85
204
  end
86
205
  end
87
206
 
88
- # Parses the service namespace from the service class name
207
+ # Converts service class name to file path namespace.
208
+ #
209
+ # Transforms a class name like "Services::ProcessPayment::Service" into
210
+ # "services/process_payment" for locating schema files.
211
+ #
212
+ # @param service_class [Class] the service class
213
+ # @return [String] underscored namespace path
214
+ #
215
+ # @example
216
+ # parse_service_namespace(Services::ProcessPayment::Service)
217
+ # # => "services/process_payment"
89
218
  #
90
- # @param service_class [Class] the service class to parse
91
- # @return [String] the service namespace
219
+ # @api private
92
220
  def self.parse_service_namespace(service_class)
93
221
  service_class.name.split('::')[..-2].map do |s|
94
222
  s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase