servus 0.3.0 → 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 (132) 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 +46 -3
  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/guard.rb +7 -6
  11. data/lib/servus/guards/falsey_guard.rb +3 -3
  12. data/lib/servus/guards/presence_guard.rb +4 -4
  13. data/lib/servus/guards/state_guard.rb +4 -5
  14. data/lib/servus/guards/truthy_guard.rb +3 -3
  15. data/lib/servus/helpers/controller_helpers.rb +40 -0
  16. data/lib/servus/support/errors.rb +16 -0
  17. data/lib/servus/support/lockdown.rb +94 -0
  18. data/lib/servus/support/logger.rb +16 -0
  19. data/lib/servus/support/validator.rb +65 -34
  20. data/lib/servus/testing/example_builders.rb +52 -0
  21. data/lib/servus/testing/matchers.rb +99 -0
  22. data/lib/servus/version.rb +1 -1
  23. data/lib/servus.rb +1 -0
  24. metadata +7 -111
  25. data/.claude/commands/check-docs.md +0 -1
  26. data/.claude/commands/consistency-check.md +0 -1
  27. data/.claude/commands/fine-tooth-comb.md +0 -1
  28. data/.claude/commands/red-green-refactor.md +0 -5
  29. data/.claude/settings.json +0 -24
  30. data/.rspec +0 -3
  31. data/.rubocop.yml +0 -27
  32. data/.yardopts +0 -6
  33. data/CHANGELOG.md +0 -169
  34. data/CLAUDE.md +0 -10
  35. data/IDEAS.md +0 -5
  36. data/LICENSE.txt +0 -21
  37. data/READme.md +0 -856
  38. data/Rakefile +0 -45
  39. data/docs/core/1_overview.md +0 -81
  40. data/docs/core/2_architecture.md +0 -120
  41. data/docs/core/3_service_objects.md +0 -154
  42. data/docs/features/1_schema_validation.md +0 -161
  43. data/docs/features/2_error_handling.md +0 -129
  44. data/docs/features/3_async_execution.md +0 -81
  45. data/docs/features/4_logging.md +0 -64
  46. data/docs/features/5_event_bus.md +0 -244
  47. data/docs/features/6_guards.md +0 -356
  48. data/docs/features/7_lazy_resolvers.md +0 -238
  49. data/docs/features/guards_naming_convention.md +0 -540
  50. data/docs/guides/1_common_patterns.md +0 -90
  51. data/docs/guides/2_migration_guide.md +0 -225
  52. data/docs/integration/1_configuration.md +0 -154
  53. data/docs/integration/2_testing.md +0 -304
  54. data/docs/integration/3_rails_integration.md +0 -99
  55. data/docs/yard/Servus/Base.html +0 -1645
  56. data/docs/yard/Servus/Config.html +0 -582
  57. data/docs/yard/Servus/Extensions/Async/Call.html +0 -400
  58. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +0 -140
  59. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +0 -154
  60. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +0 -154
  61. data/docs/yard/Servus/Extensions/Async/Errors.html +0 -128
  62. data/docs/yard/Servus/Extensions/Async/Ext.html +0 -119
  63. data/docs/yard/Servus/Extensions/Async/Job.html +0 -310
  64. data/docs/yard/Servus/Extensions/Async.html +0 -141
  65. data/docs/yard/Servus/Extensions.html +0 -117
  66. data/docs/yard/Servus/Generators/ServiceGenerator.html +0 -261
  67. data/docs/yard/Servus/Generators.html +0 -115
  68. data/docs/yard/Servus/Helpers/ControllerHelpers.html +0 -457
  69. data/docs/yard/Servus/Helpers.html +0 -115
  70. data/docs/yard/Servus/Railtie.html +0 -134
  71. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +0 -287
  72. data/docs/yard/Servus/Support/Errors/BadRequestError.html +0 -283
  73. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +0 -284
  74. data/docs/yard/Servus/Support/Errors/InternalServerError.html +0 -283
  75. data/docs/yard/Servus/Support/Errors/NotFoundError.html +0 -284
  76. data/docs/yard/Servus/Support/Errors/ServiceError.html +0 -489
  77. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +0 -290
  78. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +0 -200
  79. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +0 -288
  80. data/docs/yard/Servus/Support/Errors/ValidationError.html +0 -200
  81. data/docs/yard/Servus/Support/Errors.html +0 -140
  82. data/docs/yard/Servus/Support/Logger.html +0 -856
  83. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +0 -585
  84. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +0 -257
  85. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +0 -343
  86. data/docs/yard/Servus/Support/Rescuer.html +0 -267
  87. data/docs/yard/Servus/Support/Response.html +0 -574
  88. data/docs/yard/Servus/Support/Validator.html +0 -1150
  89. data/docs/yard/Servus/Support.html +0 -119
  90. data/docs/yard/Servus/Testing/ExampleBuilders.html +0 -523
  91. data/docs/yard/Servus/Testing/ExampleExtractor.html +0 -578
  92. data/docs/yard/Servus/Testing.html +0 -142
  93. data/docs/yard/Servus.html +0 -343
  94. data/docs/yard/_index.html +0 -535
  95. data/docs/yard/class_list.html +0 -54
  96. data/docs/yard/css/common.css +0 -1
  97. data/docs/yard/css/full_list.css +0 -58
  98. data/docs/yard/css/style.css +0 -503
  99. data/docs/yard/file.1_common_patterns.html +0 -154
  100. data/docs/yard/file.1_configuration.html +0 -115
  101. data/docs/yard/file.1_overview.html +0 -142
  102. data/docs/yard/file.1_schema_validation.html +0 -188
  103. data/docs/yard/file.2_architecture.html +0 -157
  104. data/docs/yard/file.2_error_handling.html +0 -190
  105. data/docs/yard/file.2_migration_guide.html +0 -242
  106. data/docs/yard/file.2_testing.html +0 -227
  107. data/docs/yard/file.3_async_execution.html +0 -145
  108. data/docs/yard/file.3_rails_integration.html +0 -160
  109. data/docs/yard/file.3_service_objects.html +0 -191
  110. data/docs/yard/file.4_logging.html +0 -135
  111. data/docs/yard/file.ErrorHandling.html +0 -190
  112. data/docs/yard/file.READme.html +0 -674
  113. data/docs/yard/file.architecture.html +0 -157
  114. data/docs/yard/file.async_execution.html +0 -145
  115. data/docs/yard/file.common_patterns.html +0 -154
  116. data/docs/yard/file.configuration.html +0 -115
  117. data/docs/yard/file.error_handling.html +0 -190
  118. data/docs/yard/file.logging.html +0 -135
  119. data/docs/yard/file.migration_guide.html +0 -242
  120. data/docs/yard/file.overview.html +0 -142
  121. data/docs/yard/file.rails_integration.html +0 -160
  122. data/docs/yard/file.schema_validation.html +0 -188
  123. data/docs/yard/file.service_objects.html +0 -191
  124. data/docs/yard/file.testing.html +0 -227
  125. data/docs/yard/file_list.html +0 -119
  126. data/docs/yard/frames.html +0 -22
  127. data/docs/yard/index.html +0 -674
  128. data/docs/yard/js/app.js +0 -344
  129. data/docs/yard/js/full_list.js +0 -242
  130. data/docs/yard/js/jquery.js +0 -4
  131. data/docs/yard/method_list.html +0 -542
  132. data/docs/yard/top-level-namespace.html +0 -110
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58e6098b9ea316c670b5b8aa6f61828d961cc3c8ad6e72357d71c442dfe75151
4
- data.tar.gz: 215f0f790ad36575b0b9de3f956cf585f19051c16d5250ada1ae152a969dd1d7
3
+ metadata.gz: 7e468137fc40c9d2f18214dea55b9c3fc6211e5479e73f5b0917ed168d116a2f
4
+ data.tar.gz: 5390b065cb3478d837cbd90047a7f8facaaf33273b0e2aa4d459167343354d8d
5
5
  SHA512:
6
- metadata.gz: 48142cd74cbd766846ccd9d8bf4a71a8d67136cbd60bdc948ddda6a0fa99f7e6072351b6ff98958500a6d84e49d1cb7d122e8e435efeb8b1384d39a4be9932ad
7
- data.tar.gz: 13899ee4220e2e25b888eae9b6675a77780770628717bc11a2abda9bdd82e49b0798d70c954f91e5649a3bc281d9b3ad0e50dce41374f3c2ebc42125d7321848
6
+ metadata.gz: 20e5b65a0cd82207a0c494b76e21d0562ce41ca27394b418e7d6921b841c4f47d4f8384f157146195eea713f1af3d28d09b81c210fd7f8a5041b13f8464d66a2
7
+ data.tar.gz: 52f7ca40905dc890fa6850cf87b8394471a94607749457aaeb4784c4524315fd769e40daa4322aae12b5bd84eb9282524a90836bab8a31ab9abfe01d021211a7
@@ -44,7 +44,7 @@ module Servus
44
44
  # @return [String] spec file path
45
45
  # @api private
46
46
  def handler_spec_path
47
- File.join('spec', Servus.config.events_dir, "#{file_name}_handler_spec.rb")
47
+ File.join(Servus.config.tests_dir, Servus.config.events_dir, "#{file_name}_handler_spec.rb")
48
48
  end
49
49
 
50
50
  # Returns the handler class name.
@@ -44,7 +44,7 @@ module Servus
44
44
  # @return [String] spec file path
45
45
  # @api private
46
46
  def guard_spec_path
47
- File.join('spec', Servus.config.guards_dir, "#{file_name}_guard_spec.rb")
47
+ File.join(Servus.config.tests_dir, Servus.config.guards_dir, "#{file_name}_guard_spec.rb")
48
48
  end
49
49
 
50
50
  # Returns the guard class name.
@@ -34,14 +34,16 @@ class <%= guard_class_name %> < Servus::Guard
34
34
 
35
35
  # Tests whether the <%= file_name.humanize.downcase %> condition is met.
36
36
  #
37
- # @param kwargs [Hash] the arguments to validate
37
+ # Arguments passed to `enforce_*!` / `check_*?` are stored as `@kwargs`
38
+ # and exposed via `method_missing`, so read them directly off the instance.
39
+ #
38
40
  # @return [Boolean] true if validation passes
39
- def test(**kwargs)
41
+ def test
40
42
  # TODO: Implement validation logic
41
43
  # Return true if the condition is met, false otherwise
42
44
  #
43
45
  # Example:
44
- # def test(account:, amount:)
46
+ # def test
45
47
  # account.balance >= amount
46
48
  # end
47
49
  true
@@ -57,7 +57,7 @@ module Servus
57
57
  # @return [String] spec file path
58
58
  # @api private
59
59
  def service_path_spec
60
- "spec/services/#{file_path}/service_spec.rb"
60
+ "#{Servus.config.tests_dir}/services/#{file_path}/service_spec.rb"
61
61
  end
62
62
 
63
63
  # Returns the path for the result schema file.
data/lib/servus/base.rb CHANGED
@@ -48,6 +48,7 @@ module Servus
48
48
  class Base
49
49
  include Servus::Support::Errors
50
50
  include Servus::Support::Rescuer
51
+ include Servus::Support::Lockdown
51
52
  include Servus::Events::Emitter
52
53
  include Servus::Guards
53
54
 
@@ -157,6 +158,46 @@ module Servus
157
158
  raise type, message
158
159
  end
159
160
 
161
+ # Invokes another service from within this service's {#call} and returns its
162
+ # data on success. On failure, halts the outer service with the sub-service's
163
+ # failure Response — the outer service's caller receives that Response
164
+ # unchanged (same error object, message, code, http_status).
165
+ #
166
+ # Sugar over:
167
+ #
168
+ # result = SubService.call(**params)
169
+ # return result unless result.success?
170
+ # data = result.data
171
+ #
172
+ # Only call from within a service's `#call` (or helpers reachable from
173
+ # it); the throw is caught by {Servus::Base.call}.
174
+ #
175
+ # @example Composing services
176
+ # class SendDigitalCash::Service < Servus::Base
177
+ # def call
178
+ # data1 = call!(Accounts::Lookup::Service, id: account_id)
179
+ # data2 = call!(Ledger::RecordTransfer::Service, account:, amount:)
180
+ # success(ref: data2.ref)
181
+ # end
182
+ # end
183
+ #
184
+ # For invoking a service from *outside* a service context (controllers,
185
+ # rake tasks, jobs, consoles), see
186
+ # {Servus::Helpers::ControllerHelpers#run_service!}.
187
+ #
188
+ # @param service_class [Class<Servus::Base>] the sub-service to invoke
189
+ # @param params [Hash] keyword arguments to pass to the sub-service
190
+ # @return [Servus::Support::DataObject, Object] the sub-service's data on success
191
+ # @throw [:guard_failure, Servus::Support::Response] the failure Response, otherwise
192
+ #
193
+ # @see Servus::Helpers::ControllerHelpers#run_service!
194
+ def call!(service_class, **params)
195
+ result = service_class.call(**params)
196
+ return result.data if result.success?
197
+
198
+ throw(:guard_failure, result)
199
+ end
200
+
160
201
  class << self
161
202
  # Executes the service with automatic validation, logging, and benchmarking.
162
203
  #
@@ -196,11 +237,13 @@ module Servus
196
237
 
197
238
  # Wrap execution in catch block to handle guard failures
198
239
  result = catch(:guard_failure) do
199
- benchmark(**args) { instance.call }
240
+ benchmark(**args) { instance.send(:call) }
200
241
  end
201
242
 
202
- # If result is a GuardError, a guard failed - wrap in failure Response
203
- result = Response.new(false, nil, result) if result.is_a?(Servus::Support::Errors::GuardError)
243
+ if result.is_a?(Servus::Support::Errors::GuardError)
244
+ Logger.log_guard_failure(self, result)
245
+ result = Response.new(false, nil, result)
246
+ end
204
247
 
205
248
  after_call(result, instance)
206
249
 
data/lib/servus/config.rb CHANGED
@@ -51,22 +51,90 @@ module Servus
51
51
  # @return [String] the guards directory path
52
52
  attr_accessor :guards_dir
53
53
 
54
+ # The directory where generated spec/test files are placed.
55
+ #
56
+ # Defaults to `"spec"`. Projects using Minitest or a custom test layout
57
+ # can override this (e.g., `"test"`) so generators write files into the
58
+ # correct location.
59
+ #
60
+ # @return [String] the tests directory path
61
+ attr_accessor :tests_dir
62
+
54
63
  # Whether to include the default built-in guards (EnsurePresent, EnsurePositive).
55
64
  #
56
65
  # @return [Boolean] true to include default guards, false to exclude them
57
66
  attr_accessor :include_default_guards
58
67
 
68
+ # Whether to require all services to define an arguments schema.
69
+ #
70
+ # When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
71
+ # a service is called without an arguments schema defined.
72
+ #
73
+ # @return [Boolean] true to require arguments schemas, false to allow schema-less services
74
+ attr_accessor :require_service_arguments_schema
75
+
76
+ # Whether to require all services to define a result schema.
77
+ #
78
+ # When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
79
+ # a service returns a successful response without a result schema defined.
80
+ # Failure schemas remain optional regardless of this setting.
81
+ #
82
+ # @return [Boolean] true to require result schemas, false to allow schema-less services
83
+ attr_accessor :require_service_result_schema
84
+
85
+ # Whether to require all event handlers to define a payload schema.
86
+ #
87
+ # When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
88
+ # an event handler validates a payload without a payload schema defined.
89
+ #
90
+ # @return [Boolean] true to require payload schemas, false to allow schema-less handlers
91
+ attr_accessor :require_event_payload_schema
92
+
93
+ # Whether external instantiation of services is blocked and instance
94
+ # `#call` methods are automatically privatized.
95
+ #
96
+ # When enabled (default), callers must invoke services via the class
97
+ # method {Servus::Base.call}, which runs argument validation, logging,
98
+ # benchmarking, guards, result validation, and event emission. Calling
99
+ # `MyService.new` or `instance.call` directly raises `NoMethodError`.
100
+ #
101
+ # Disable this if you have existing code that instantiates services
102
+ # directly or otherwise prefer to opt out of the enforcement.
103
+ #
104
+ # @return [Boolean] true to enforce lockdown (default), false to allow
105
+ # direct instantiation and public instance `#call`
106
+ # @see Servus::Support::Lockdown
107
+ attr_reader :lockdown_enabled
108
+
109
+ # Sets whether lockdown is enforced, immediately re-applying the
110
+ # resulting `.new` visibility to {Servus::Base}.
111
+ #
112
+ # @param value [Boolean] the new lockdown setting
113
+ # @return [Boolean] the new value
114
+ def lockdown_enabled=(value)
115
+ @lockdown_enabled = value
116
+ Servus::Base.apply_lockdown!
117
+ end
118
+
59
119
  # Initializes a new configuration with default values.
60
120
  #
61
121
  # @api private
62
122
  def initialize
123
+ set_default_directories
124
+ @strict_event_validation = true
125
+ @include_default_guards = true
126
+ @lockdown_enabled = true
127
+ @require_service_arguments_schema = false
128
+ @require_service_result_schema = false
129
+ @require_event_payload_schema = false
130
+ end
131
+
132
+ def set_default_directories
63
133
  @guards_dir = 'app/guards'
64
134
  @events_dir = 'app/events'
65
135
  @schemas_dir = 'app/schemas'
66
136
  @services_dir = 'app/services'
67
-
68
- @strict_event_validation = true
69
- @include_default_guards = true
137
+ @tests_dir = 'spec'
70
138
  end
71
139
 
72
140
  # Returns the full path to a service's schema file.
@@ -91,6 +91,35 @@ module Servus
91
91
  ActiveSupport::Notifications.instrument(notification_name(event_name), payload)
92
92
  end
93
93
 
94
+ # Subscribes to all Servus event emissions.
95
+ #
96
+ # Yields the clean event name and payload as positional args, plus
97
+ # +started_at+, +finished_at+, and +id+ as keyword args.
98
+ # Returns the subscription for manual unsubscribe.
99
+ #
100
+ # @yield [event_name, payload, started_at:, finished_at:, id:]
101
+ # @yieldparam event_name [Symbol] the event name
102
+ # @yieldparam payload [Hash] the event payload
103
+ # @yieldparam started_at [Time] when the event was emitted
104
+ # @yieldparam finished_at [Time] when the instrumented block completed
105
+ # @yieldparam id [String] unique notification ID
106
+ # @return [Object] the ActiveSupport::Notifications subscription
107
+ #
108
+ # @example Forward all events to an external system
109
+ # Servus::Events::Bus.subscribe_all do |event_name, payload, started_at:, **|
110
+ # EventusForwardJob.perform_later(
111
+ # event: event_name.to_s,
112
+ # payload: payload.as_json,
113
+ # occurred_at: started_at.utc.iso8601(6)
114
+ # )
115
+ # end
116
+ def subscribe_all(&block)
117
+ ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, started, finished, id, payload|
118
+ event_name = name.delete_prefix('servus.events.').to_sym
119
+ block.call(event_name, payload, started_at: started, finished_at: finished, id: id)
120
+ end
121
+ end
122
+
94
123
  # Clears all registered handlers and unsubscribes from notifications.
95
124
  #
96
125
  # Useful for testing and development mode reloading.
@@ -127,6 +127,8 @@ module Servus
127
127
  def emit_events_for(trigger, result)
128
128
  self.class.emissions_for(trigger).each do |emission|
129
129
  payload = build_event_payload(emission, result)
130
+ validate_event_payload!(emission[:event_name], payload)
131
+ Servus::Support::Logger.log_event(emission[:event_name], payload)
130
132
  Servus::Events::Bus.emit(emission[:event_name], payload)
131
133
  end
132
134
  end
@@ -134,6 +136,19 @@ module Servus
134
136
  # Instance methods for emitting events during service execution
135
137
  private
136
138
 
139
+ # Validates the payload against all handler schemas registered for the event.
140
+ #
141
+ # @param event_name [Symbol] the event name
142
+ # @param payload [Hash] the event payload
143
+ # @return [void]
144
+ # @raise [Servus::Support::Errors::ValidationError] if payload fails validation
145
+ # @api private
146
+ def validate_event_payload!(event_name, payload)
147
+ Servus::Events::Bus.handlers_for(event_name).each do |handler_class|
148
+ Servus::Support::Validator.validate_event_payload!(handler_class, payload)
149
+ end
150
+ end
151
+
137
152
  # Builds the event payload using the configured payload builder or defaults.
138
153
  #
139
154
  # @param emission [Hash] the emission configuration
data/lib/servus/guard.rb CHANGED
@@ -19,7 +19,7 @@ module Servus
19
19
  # }
20
20
  # end
21
21
  #
22
- # def test(account:, amount:)
22
+ # def test
23
23
  # account.balance >= amount
24
24
  # end
25
25
  # end
@@ -52,7 +52,7 @@ module Servus
52
52
  # Servus::Guard.execute!(EnsurePositive, amount: -10) # throws :guard_failure
53
53
  def execute!(guard_class, **)
54
54
  guard = guard_class.new(**)
55
- return if guard.test(**)
55
+ return if guard.test
56
56
 
57
57
  throw(:guard_failure, guard.error)
58
58
  end
@@ -69,7 +69,7 @@ module Servus
69
69
  # Servus::Guard.execute?(EnsurePositive, amount: 100) # => true
70
70
  # Servus::Guard.execute?(EnsurePositive, amount: -10) # => false
71
71
  def execute?(guard_class, **)
72
- guard_class.new(**).test(**)
72
+ guard_class.new(**).test
73
73
  end
74
74
 
75
75
  # Declares the HTTP status code for API responses.
@@ -218,14 +218,15 @@ module Servus
218
218
 
219
219
  # Tests whether the guard passes.
220
220
  #
221
- # Subclasses must implement this method with explicit keyword arguments
222
- # that define the guard's contract.
221
+ # Subclasses must implement this zero-argument method. Arguments passed
222
+ # to `new` are stored as `@kwargs` and exposed via `method_missing`, so
223
+ # `test` reads them directly off the instance.
223
224
  #
224
225
  # @return [Boolean] true if the guard passes, false otherwise
225
226
  # @raise [NotImplementedError] if not implemented by subclass
226
227
  #
227
228
  # @example
228
- # def test(account:, amount:)
229
+ # def test
229
230
  # account.balance >= amount
230
231
  # end
231
232
  def test
@@ -24,10 +24,10 @@ module Servus
24
24
 
25
25
  # Tests whether all specified attributes are falsey.
26
26
  #
27
- # @param on [Object] the object to check
28
- # @param check [Symbol, Array<Symbol>] attribute(s) to verify
27
+ # Reads `on` and `check` from the kwargs stored at initialization.
28
+ #
29
29
  # @return [Boolean] true if all attributes are falsey
30
- def test(on:, check:)
30
+ def test
31
31
  Array(check).all? { |attr| !on.public_send(attr) }
32
32
  end
33
33
 
@@ -36,12 +36,12 @@ module Servus
36
36
  # Tests whether all provided values are present.
37
37
  #
38
38
  # A value is considered present if it is not nil and not empty
39
- # (for values that respond to empty?).
39
+ # (for values that respond to empty?). Reads all values from the
40
+ # kwargs stored at initialization.
40
41
  #
41
- # @param values [Hash] keyword arguments to validate
42
42
  # @return [Boolean] true if all values are present
43
- def test(**values)
44
- values.all? { |_, value| present?(value) }
43
+ def test
44
+ kwargs.all? { |_, value| present?(value) }
45
45
  end
46
46
 
47
47
  private
@@ -24,12 +24,11 @@ module Servus
24
24
 
25
25
  # Tests whether the attribute matches the expected value(s).
26
26
  #
27
- # @param on [Object] the object to check
28
- # @param check [Symbol] the attribute to verify
29
- # @param is [Object, Array] expected value(s) - passes if attribute matches any
27
+ # Reads `on`, `check`, and `is` from the kwargs stored at initialization.
28
+ #
30
29
  # @return [Boolean] true if attribute matches expected value(s)
31
- def test(on:, check:, is:) # rubocop:disable Naming/MethodParameterName
32
- Array(is).include?(on.public_send(check))
30
+ def test
31
+ Array(kwargs[:is]).include?(on.public_send(check))
33
32
  end
34
33
 
35
34
  private
@@ -24,10 +24,10 @@ module Servus
24
24
 
25
25
  # Tests whether all specified attributes are truthy.
26
26
  #
27
- # @param on [Object] the object to check
28
- # @param check [Symbol, Array<Symbol>] attribute(s) to verify
27
+ # Reads `on` and `check` from the kwargs stored at initialization.
28
+ #
29
29
  # @return [Boolean] true if all attributes are truthy
30
- def test(on:, check:)
30
+ def test
31
31
  Array(check).all? { |attr| !!on.public_send(attr) }
32
32
  end
33
33
 
@@ -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
@@ -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