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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f20bb1c0b41657377b94a29aef1915dc7a0c4c65442d21b9f6a60c75537dd8a
4
- data.tar.gz: 9273a9d2e3efa50c3ce2d80935e7538a1fa89e0cc3195acfde974621f7960277
3
+ metadata.gz: 7e468137fc40c9d2f18214dea55b9c3fc6211e5479e73f5b0917ed168d116a2f
4
+ data.tar.gz: 5390b065cb3478d837cbd90047a7f8facaaf33273b0e2aa4d459167343354d8d
5
5
  SHA512:
6
- metadata.gz: e2e4ec7e3390784c35acbe238b3d809c4e07859c39ccd159a1618daf3439d04d7bb4eb3c847ea30e1a3b94a1d4dfac2508601ce263160ddee1d7e0a9e20ed13b
7
- data.tar.gz: 4f135793c078f9230436a6ff3f9508847469a198dfa818afadcbace146b7b85fdb78844a864538b96bded6cb3eed910002c0fdc8b6802ad4cae3189e06349a54
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
 
@@ -88,6 +89,8 @@ module Servus
88
89
  # The failure is logged automatically and returns a response containing the error.
89
90
  #
90
91
  # @param message [String, nil] custom error message (uses error type's default if nil)
92
+ # @param data [Object, nil] optional structured data to attach to the failure response.
93
+ # When a +failure+ schema is defined, this data will be validated against it.
91
94
  # @param type [Class] error class to instantiate (must inherit from ServiceError)
92
95
  # @return [Servus::Support::Response] response with success: false and the error
93
96
  #
@@ -109,12 +112,17 @@ module Servus
109
112
  # # Uses "Not found" as the message
110
113
  # end
111
114
  #
115
+ # @example Attaching structured data to a failure
116
+ # def call
117
+ # return failure("Approval required", data: { requires_human_approval: true })
118
+ # end
119
+ #
112
120
  # @see #success
113
121
  # @see #error!
114
122
  # @see Servus::Support::Errors
115
- def failure(message = nil, type: Servus::Support::Errors::ServiceError)
123
+ def failure(message = nil, data: nil, type: Servus::Support::Errors::ServiceError)
116
124
  error = type.new(message)
117
- Response.new(false, nil, error)
125
+ Response.new(false, data, error)
118
126
  end
119
127
 
120
128
  # Logs an error and raises an exception, halting service execution.
@@ -150,6 +158,46 @@ module Servus
150
158
  raise type, message
151
159
  end
152
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
+
153
201
  class << self
154
202
  # Executes the service with automatic validation, logging, and benchmarking.
155
203
  #
@@ -189,11 +237,13 @@ module Servus
189
237
 
190
238
  # Wrap execution in catch block to handle guard failures
191
239
  result = catch(:guard_failure) do
192
- benchmark(**args) { instance.call }
240
+ benchmark(**args) { instance.send(:call) }
193
241
  end
194
242
 
195
- # If result is a GuardError, a guard failed - wrap in failure Response
196
- 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
197
247
 
198
248
  after_call(result, instance)
199
249
 
@@ -207,15 +257,16 @@ module Servus
207
257
  end
208
258
  # rubocop:enable Metrics/MethodLength
209
259
 
210
- # Defines schema validation rules for the service's arguments and/or result.
260
+ # Defines schema validation rules for the service's arguments, result, and/or failure data.
211
261
  #
212
262
  # This method provides a clean DSL for specifying JSON schemas that will be used
213
263
  # to validate service inputs and outputs. Schemas defined via this method take
214
- # precedence over ARGUMENTS_SCHEMA and RESULT_SCHEMA constants. The next major
215
- # version will deprecate those constants in favor of this DSL.
264
+ # precedence over ARGUMENTS_SCHEMA, RESULT_SCHEMA, and FAILURE_SCHEMA constants.
265
+ # The next major version will deprecate those constants in favor of this DSL.
216
266
  #
217
267
  # @param arguments [Hash, nil] JSON schema for validating service arguments
218
268
  # @param result [Hash, nil] JSON schema for validating service result data
269
+ # @param failure [Hash, nil] JSON schema for validating failure response data
219
270
  # @return [void]
220
271
  #
221
272
  # @example Defining both arguments and result schemas
@@ -245,9 +296,10 @@ module Servus
245
296
  # end
246
297
  #
247
298
  # @see Servus::Support::Validator
248
- def schema(arguments: nil, result: nil)
299
+ def schema(arguments: nil, result: nil, failure: nil)
249
300
  @arguments_schema = arguments.with_indifferent_access if arguments
250
301
  @result_schema = result.with_indifferent_access if result
302
+ @failure_schema = failure.with_indifferent_access if failure
251
303
  end
252
304
 
253
305
  # Returns the arguments schema defined via the schema DSL method.
@@ -262,6 +314,12 @@ module Servus
262
314
  # @api private
263
315
  attr_reader :result_schema
264
316
 
317
+ # Returns the failure schema defined via the schema DSL method.
318
+ #
319
+ # @return [Hash, nil] the failure schema or nil if not defined
320
+ # @api private
321
+ attr_reader :failure_schema
322
+
265
323
  # Executes pre-call hooks including logging and argument validation.
266
324
  #
267
325
  # This method is automatically called before service execution and handles:
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
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Extensions
5
+ module Lazily
6
+ # Provides lazy record resolution for service inputs.
7
+ #
8
+ # This module extends {Servus::Base} with the {#lazily} class method, enabling
9
+ # services to accept either a record ID or an already-loaded record instance.
10
+ # Resolution happens lazily on first access and is memoized.
11
+ #
12
+ # @see Lazily#lazily
13
+ module Call
14
+ # Declares a lazy record resolver for a service input.
15
+ #
16
+ # Defines an accessor method that lazily resolves the input value to a record.
17
+ # If the value is already an instance of the target class, it is returned directly.
18
+ # If the value is an ID (or other lookup value), it is resolved via the target
19
+ # class's +.find+ or +.find_by!+ method. Arrays are resolved via +.where+.
20
+ #
21
+ # The resolved record is written back to the instance variable, so subsequent
22
+ # calls return the same object without re-querying.
23
+ #
24
+ # @param name [Symbol] the param/ivar name, also becomes the accessor method
25
+ # @param finds [Class] the model class to resolve against (e.g., +User+, +Account+)
26
+ # @param by [Symbol] the lookup column (default: +:id+). When +:id+, uses +.find+.
27
+ # Otherwise uses +.find_by!(column: value)+.
28
+ # @return [void]
29
+ #
30
+ # @example Basic usage with .find
31
+ # lazily :user, finds: User
32
+ # # user: 123 → User.find(123)
33
+ # # user: user_inst → returns user_inst directly
34
+ #
35
+ # @example Custom column lookup
36
+ # lazily :account, finds: Account, by: :uuid
37
+ # # account: "abc-def" → Account.find_by!(uuid: "abc-def")
38
+ #
39
+ # @example Array input
40
+ # lazily :users, finds: User
41
+ # # users: [1, 2, 3] → User.where(id: [1, 2, 3])
42
+ #
43
+ # @note Only available when ActiveRecord is loaded (via Railtie)
44
+ # @see Servus::Base.call
45
+ def lazily(name, finds:, by: :id)
46
+ (@lazy_resolvers ||= {})[name] = { klass: finds, by: by }
47
+ define_resolver_method(name, finds, by)
48
+ end
49
+
50
+ # Returns the hash of registered lazy resolvers for this service class.
51
+ #
52
+ # @return [Hash{Symbol => Hash}] resolver configurations keyed by name
53
+ # @api private
54
+ def lazy_resolvers
55
+ @lazy_resolvers || {}
56
+ end
57
+
58
+ private
59
+
60
+ # Defines a lazy accessor method on a prepended module.
61
+ #
62
+ # @param name [Symbol] the method/ivar name
63
+ # @param klass [Class] the target model class
64
+ # @param by [Symbol] the lookup column
65
+ # @api private
66
+ def define_resolver_method(name, klass, by)
67
+ mod = (@_resolver_module ||= Module.new)
68
+ prepend(mod) unless ancestors.include?(mod)
69
+
70
+ mod.define_method(name) do
71
+ @_lazily_resolved ||= {}
72
+ return instance_variable_get(:"@#{name}") if @_lazily_resolved[name]
73
+
74
+ resolved = Resolver.call(instance_variable_get(:"@#{name}"), klass: klass, by: by, name: name)
75
+ @_lazily_resolved[name] = true
76
+ instance_variable_set(:"@#{name}", resolved)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Extensions
5
+ module Lazily
6
+ # Error classes for lazy record resolution.
7
+ #
8
+ # These errors are raised when lazy resolution fails, such as when
9
+ # a required record reference is nil.
10
+ module Errors
11
+ # Base error class for all lazily extension errors.
12
+ #
13
+ # All lazy resolution errors inherit from this class for easy rescue handling.
14
+ class LazilyError < StandardError; end
15
+
16
+ # Raised when a lazily-resolved record reference is nil.
17
+ #
18
+ # This occurs when a service declares +lazily :user, finds: User+ but
19
+ # receives +user: nil+. A nil reference is always a bug at the call site.
20
+ #
21
+ # @example
22
+ # class MyService < Servus::Base
23
+ # lazily :user, finds: User
24
+ #
25
+ # def initialize(user:)
26
+ # @user = user
27
+ # end
28
+ #
29
+ # def call
30
+ # user # => raises NotFoundError if @user is nil
31
+ # end
32
+ # end
33
+ class NotFoundError < LazilyError; end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Extensions
5
+ # Lazy record resolution extensions for Servus services.
6
+ #
7
+ # This module provides the infrastructure for lazily resolving record
8
+ # references (IDs or instances) in service inputs. When loaded, it extends
9
+ # {Servus::Base} with the {Call#lazily} class method.
10
+ #
11
+ # @see Servus::Extensions::Lazily::Call
12
+ module Lazily
13
+ require 'servus/extensions/lazily/errors'
14
+ require 'servus/extensions/lazily/resolver'
15
+ require 'servus/extensions/lazily/call'
16
+
17
+ # Extension module for lazily functionality.
18
+ #
19
+ # @api private
20
+ module Ext; end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Extensions
5
+ module Lazily
6
+ # Performs the actual record resolution for a lazily-declared input.
7
+ #
8
+ # Handles the decision logic: is the raw value already an instance,
9
+ # nil, an array, or a lookup value? Delegates to the appropriate
10
+ # finder method on the target class.
11
+ #
12
+ # @api private
13
+ class Resolver
14
+ # Resolves a raw value to a record (or collection of records).
15
+ #
16
+ # @param raw [Object] the raw input value (ID, instance, Array, or nil)
17
+ # @param klass [Class] the target model class
18
+ # @param by [Symbol] the lookup column
19
+ # @param name [Symbol] the param name (for error messages)
20
+ # @return [Object] the resolved record or collection
21
+ # @raise [Errors::NotFoundError] if raw is nil
22
+ def self.call(raw, klass:, by:, name:)
23
+ return raw if raw.is_a?(klass)
24
+ raise Errors::NotFoundError, "Couldn't find #{klass} (#{name} was nil)" if raw.nil?
25
+ return klass.where(by => raw) if raw.is_a?(Array)
26
+
27
+ by == :id ? klass.find(raw) : klass.find_by!(by => raw)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
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