servus 0.2.1 → 0.4.0

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