servus 0.3.0 → 0.5.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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/servus/event/event_generator.rb +54 -0
  3. data/lib/generators/servus/event/templates/event.rb.erb +44 -0
  4. data/lib/generators/servus/event/templates/event_spec.rb.erb +20 -0
  5. data/lib/generators/servus/guard/guard_generator.rb +1 -1
  6. data/lib/generators/servus/guard/templates/guard.rb.erb +5 -3
  7. data/lib/generators/servus/service/service_generator.rb +1 -1
  8. data/lib/servus/base.rb +46 -3
  9. data/lib/servus/config.rb +85 -12
  10. data/lib/servus/event.rb +235 -0
  11. data/lib/servus/events/bus.rb +111 -72
  12. data/lib/servus/events/class_router.rb +40 -0
  13. data/lib/servus/events/emitter.rb +21 -6
  14. data/lib/servus/events/invocation.rb +94 -0
  15. data/lib/servus/events/router.rb +44 -0
  16. data/lib/servus/guard.rb +7 -6
  17. data/lib/servus/guards/falsey_guard.rb +3 -3
  18. data/lib/servus/guards/presence_guard.rb +4 -4
  19. data/lib/servus/guards/state_guard.rb +4 -5
  20. data/lib/servus/guards/truthy_guard.rb +3 -3
  21. data/lib/servus/helpers/controller_helpers.rb +40 -0
  22. data/lib/servus/railtie.rb +10 -8
  23. data/lib/servus/support/errors.rb +16 -0
  24. data/lib/servus/support/lockdown.rb +94 -0
  25. data/lib/servus/support/logger.rb +18 -0
  26. data/lib/servus/support/validator.rb +70 -40
  27. data/lib/servus/testing/example_builders.rb +52 -0
  28. data/lib/servus/testing/matchers.rb +103 -4
  29. data/lib/servus/version.rb +1 -1
  30. data/lib/servus.rb +7 -2
  31. metadata +14 -116
  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 -169
  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 -81
  47. data/docs/core/2_architecture.md +0 -120
  48. data/docs/core/3_service_objects.md +0 -154
  49. data/docs/features/1_schema_validation.md +0 -161
  50. data/docs/features/2_error_handling.md +0 -129
  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/7_lazy_resolvers.md +0 -238
  56. data/docs/features/guards_naming_convention.md +0 -540
  57. data/docs/guides/1_common_patterns.md +0 -90
  58. data/docs/guides/2_migration_guide.md +0 -225
  59. data/docs/integration/1_configuration.md +0 -154
  60. data/docs/integration/2_testing.md +0 -304
  61. data/docs/integration/3_rails_integration.md +0 -99
  62. data/docs/yard/Servus/Base.html +0 -1645
  63. data/docs/yard/Servus/Config.html +0 -582
  64. data/docs/yard/Servus/Extensions/Async/Call.html +0 -400
  65. data/docs/yard/Servus/Extensions/Async/Errors/AsyncError.html +0 -140
  66. data/docs/yard/Servus/Extensions/Async/Errors/JobEnqueueError.html +0 -154
  67. data/docs/yard/Servus/Extensions/Async/Errors/ServiceNotFoundError.html +0 -154
  68. data/docs/yard/Servus/Extensions/Async/Errors.html +0 -128
  69. data/docs/yard/Servus/Extensions/Async/Ext.html +0 -119
  70. data/docs/yard/Servus/Extensions/Async/Job.html +0 -310
  71. data/docs/yard/Servus/Extensions/Async.html +0 -141
  72. data/docs/yard/Servus/Extensions.html +0 -117
  73. data/docs/yard/Servus/Generators/ServiceGenerator.html +0 -261
  74. data/docs/yard/Servus/Generators.html +0 -115
  75. data/docs/yard/Servus/Helpers/ControllerHelpers.html +0 -457
  76. data/docs/yard/Servus/Helpers.html +0 -115
  77. data/docs/yard/Servus/Railtie.html +0 -134
  78. data/docs/yard/Servus/Support/Errors/AuthenticationError.html +0 -287
  79. data/docs/yard/Servus/Support/Errors/BadRequestError.html +0 -283
  80. data/docs/yard/Servus/Support/Errors/ForbiddenError.html +0 -284
  81. data/docs/yard/Servus/Support/Errors/InternalServerError.html +0 -283
  82. data/docs/yard/Servus/Support/Errors/NotFoundError.html +0 -284
  83. data/docs/yard/Servus/Support/Errors/ServiceError.html +0 -489
  84. data/docs/yard/Servus/Support/Errors/ServiceUnavailableError.html +0 -290
  85. data/docs/yard/Servus/Support/Errors/UnauthorizedError.html +0 -200
  86. data/docs/yard/Servus/Support/Errors/UnprocessableEntityError.html +0 -288
  87. data/docs/yard/Servus/Support/Errors/ValidationError.html +0 -200
  88. data/docs/yard/Servus/Support/Errors.html +0 -140
  89. data/docs/yard/Servus/Support/Logger.html +0 -856
  90. data/docs/yard/Servus/Support/Rescuer/BlockContext.html +0 -585
  91. data/docs/yard/Servus/Support/Rescuer/CallOverride.html +0 -257
  92. data/docs/yard/Servus/Support/Rescuer/ClassMethods.html +0 -343
  93. data/docs/yard/Servus/Support/Rescuer.html +0 -267
  94. data/docs/yard/Servus/Support/Response.html +0 -574
  95. data/docs/yard/Servus/Support/Validator.html +0 -1150
  96. data/docs/yard/Servus/Support.html +0 -119
  97. data/docs/yard/Servus/Testing/ExampleBuilders.html +0 -523
  98. data/docs/yard/Servus/Testing/ExampleExtractor.html +0 -578
  99. data/docs/yard/Servus/Testing.html +0 -142
  100. data/docs/yard/Servus.html +0 -343
  101. data/docs/yard/_index.html +0 -535
  102. data/docs/yard/class_list.html +0 -54
  103. data/docs/yard/css/common.css +0 -1
  104. data/docs/yard/css/full_list.css +0 -58
  105. data/docs/yard/css/style.css +0 -503
  106. data/docs/yard/file.1_common_patterns.html +0 -154
  107. data/docs/yard/file.1_configuration.html +0 -115
  108. data/docs/yard/file.1_overview.html +0 -142
  109. data/docs/yard/file.1_schema_validation.html +0 -188
  110. data/docs/yard/file.2_architecture.html +0 -157
  111. data/docs/yard/file.2_error_handling.html +0 -190
  112. data/docs/yard/file.2_migration_guide.html +0 -242
  113. data/docs/yard/file.2_testing.html +0 -227
  114. data/docs/yard/file.3_async_execution.html +0 -145
  115. data/docs/yard/file.3_rails_integration.html +0 -160
  116. data/docs/yard/file.3_service_objects.html +0 -191
  117. data/docs/yard/file.4_logging.html +0 -135
  118. data/docs/yard/file.ErrorHandling.html +0 -190
  119. data/docs/yard/file.READme.html +0 -674
  120. data/docs/yard/file.architecture.html +0 -157
  121. data/docs/yard/file.async_execution.html +0 -145
  122. data/docs/yard/file.common_patterns.html +0 -154
  123. data/docs/yard/file.configuration.html +0 -115
  124. data/docs/yard/file.error_handling.html +0 -190
  125. data/docs/yard/file.logging.html +0 -135
  126. data/docs/yard/file.migration_guide.html +0 -242
  127. data/docs/yard/file.overview.html +0 -142
  128. data/docs/yard/file.rails_integration.html +0 -160
  129. data/docs/yard/file.schema_validation.html +0 -188
  130. data/docs/yard/file.service_objects.html +0 -191
  131. data/docs/yard/file.testing.html +0 -227
  132. data/docs/yard/file_list.html +0 -119
  133. data/docs/yard/frames.html +0 -22
  134. data/docs/yard/index.html +0 -674
  135. data/docs/yard/js/app.js +0 -344
  136. data/docs/yard/js/full_list.js +0 -242
  137. data/docs/yard/js/jquery.js +0 -4
  138. data/docs/yard/method_list.html +0 -542
  139. data/docs/yard/top-level-namespace.html +0 -110
  140. data/lib/generators/servus/event_handler/event_handler_generator.rb +0 -59
  141. data/lib/generators/servus/event_handler/templates/handler.rb.erb +0 -86
  142. data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +0 -48
  143. data/lib/servus/event_handler.rb +0 -290
  144. data/lib/servus/events/errors.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58e6098b9ea316c670b5b8aa6f61828d961cc3c8ad6e72357d71c442dfe75151
4
- data.tar.gz: 215f0f790ad36575b0b9de3f956cf585f19051c16d5250ada1ae152a969dd1d7
3
+ metadata.gz: 2f382ed3d92dd577c93965ae207c00d28cac6dfddf452cf22fb5dafcd69d56eb
4
+ data.tar.gz: f5a4394a33f5a3061d3c5746fdd058eb457cb29baa2f0adb9cc97c0679f5c158
5
5
  SHA512:
6
- metadata.gz: 48142cd74cbd766846ccd9d8bf4a71a8d67136cbd60bdc948ddda6a0fa99f7e6072351b6ff98958500a6d84e49d1cb7d122e8e435efeb8b1384d39a4be9932ad
7
- data.tar.gz: 13899ee4220e2e25b888eae9b6675a77780770628717bc11a2abda9bdd82e49b0798d70c954f91e5649a3bc281d9b3ad0e50dce41374f3c2ebc42125d7321848
6
+ metadata.gz: 5ccb1adc1b0bd4b7ff9f8b754571be541bbf7e3b9246746fe73c9e1259ad4b6f6e3a5a4d6c07d3b9f30cb8ddda94dfc2cad7c78fb6dc31fb17440c1d3a19b6be
7
+ data.tar.gz: 0107e7e4d770913a5e86de5c496791e6311c913dc6b2bdad03417b78a2e66be6d50c2fc5d213f87b51162ac4a15d3c6c9dcb9b323267a9db9d0b222099f60245
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Generators
5
+ # Rails generator for creating Servus event classes.
6
+ #
7
+ # Generates an event class and spec file. The event name is inferred
8
+ # from the class name — no explicit +event_name+ call needed.
9
+ #
10
+ # @example Generate an event
11
+ # rails g servus:event referral_verified
12
+ #
13
+ # @example Generated files
14
+ # app/events/referral_verified.rb
15
+ # spec/app/events/referral_verified_spec.rb
16
+ #
17
+ # @see https://guides.rubyonrails.org/generators.html
18
+ class EventGenerator < Rails::Generators::NamedBase
19
+ source_root File.expand_path('templates', __dir__)
20
+
21
+ class_option :no_docs, type: :boolean,
22
+ default: false,
23
+ desc: 'Skip documentation comments in generated files'
24
+
25
+ # Creates the event class and spec files.
26
+ #
27
+ # @return [void]
28
+ def create_event_file
29
+ template 'event.rb.erb', event_path
30
+ template 'event_spec.rb.erb', event_spec_path
31
+ end
32
+
33
+ private
34
+
35
+ # @return [String] event file path
36
+ # @api private
37
+ def event_path
38
+ File.join(Servus.config.events_dir, "#{file_name}_event.rb")
39
+ end
40
+
41
+ # @return [String] spec file path
42
+ # @api private
43
+ def event_spec_path
44
+ File.join(Servus.config.tests_dir, Servus.config.events_dir, "#{file_name}_event_spec.rb")
45
+ end
46
+
47
+ # @return [String] event class name (e.g. "ReferralVerifiedEvent")
48
+ # @api private
49
+ def event_class_name
50
+ "#{class_name}Event"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ <%- unless options[:no_docs] -%>
4
+ # Defines the :<%= file_name %> event.
5
+ #
6
+ # Event classes declare the contract (schema) for an event and optionally
7
+ # wire up service invocations that run when the event fires. The event
8
+ # name is inferred from the class name.
9
+ #
10
+ # @example Emit this event from anywhere
11
+ # <%= event_class_name %>.emit({ user_id: 123 })
12
+ #
13
+ # @example Invoke a service when this event fires
14
+ # invoke SendEmail::Service, async: true do |payload|
15
+ # { user_id: payload[:user_id] }
16
+ # end
17
+ #
18
+ # @example Pass full payload through (no mapper block)
19
+ # invoke AuditLogger::Service, async: true
20
+ #
21
+ # @example Conditional invocation
22
+ # invoke GrantRewards::Service, if: ->(payload) { payload[:premium] } do |payload|
23
+ # { user_id: payload[:user_id] }
24
+ # end
25
+ #
26
+ # Available options for `invoke`:
27
+ # - async: true - Invoke service asynchronously via ActiveJob
28
+ # - queue: :queue_name - Specify ActiveJob queue (requires async: true)
29
+ # - if: ->(payload) {} - Condition that must be true to invoke
30
+ # - unless: ->(payload) {} - Condition that must be false to invoke
31
+ #
32
+ # @see Servus::Event
33
+ # @see Servus::Events::Bus
34
+ <%- end -%>
35
+ class <%= event_class_name %> < Servus::Event
36
+ schema payload: {
37
+ type: 'object',
38
+ description: '<%= event_class_name %> event payload',
39
+ }
40
+
41
+ # invoke YourService, async: true do |payload|
42
+ # { example_arg: payload[:example_field] }
43
+ # end
44
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ RSpec.describe <%= event_class_name %> do
6
+ let(:payload) do
7
+ {
8
+ # TODO: Add sample payload fields
9
+ }
10
+ end
11
+
12
+ <%- unless options[:no_docs] -%>
13
+ # TODO: Add tests for service invocations
14
+ # it 'invokes YourService with mapped arguments' do
15
+ # expect { described_class.emit(payload) }
16
+ # .to emit_event(:<%= file_name %>)
17
+ # .with(hash_including(expected_field: 'value'))
18
+ # end
19
+ <%- end -%>
20
+ end
@@ -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
@@ -22,7 +22,7 @@ module Servus
22
22
  # @return [String] the schemas directory path
23
23
  attr_accessor :schemas_dir
24
24
 
25
- # The directory where event handlers are located.
25
+ # The directory where Event classes are located.
26
26
  #
27
27
  # Defaults to `Rails.root/app/events` in Rails applications.
28
28
  #
@@ -36,14 +36,6 @@ module Servus
36
36
  # @return [String] the services directory path
37
37
  attr_accessor :services_dir
38
38
 
39
- # Whether to validate that all event handlers subscribe to events that are actually emitted by services.
40
- #
41
- # When enabled, raises an error on boot if handlers subscribe to non-existent events.
42
- # Helps catch typos and orphaned handlers.
43
- #
44
- # @return [Boolean] true to validate, false to skip validation
45
- attr_accessor :strict_event_validation
46
-
47
39
  # The directory where guard classes are located.
48
40
  #
49
41
  # Defaults to `Rails.root/app/guards` in Rails applications.
@@ -51,22 +43,103 @@ module Servus
51
43
  # @return [String] the guards directory path
52
44
  attr_accessor :guards_dir
53
45
 
46
+ # The directory where generated spec/test files are placed.
47
+ #
48
+ # Defaults to `"spec"`. Projects using Minitest or a custom test layout
49
+ # can override this (e.g., `"test"`) so generators write files into the
50
+ # correct location.
51
+ #
52
+ # @return [String] the tests directory path
53
+ attr_accessor :tests_dir
54
+
54
55
  # Whether to include the default built-in guards (EnsurePresent, EnsurePositive).
55
56
  #
56
57
  # @return [Boolean] true to include default guards, false to exclude them
57
58
  attr_accessor :include_default_guards
58
59
 
60
+ # Whether to require all services to define an arguments schema.
61
+ #
62
+ # When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
63
+ # a service is called without an arguments schema defined.
64
+ #
65
+ # @return [Boolean] true to require arguments schemas, false to allow schema-less services
66
+ attr_accessor :require_service_arguments_schema
67
+
68
+ # Whether to require all services to define a result schema.
69
+ #
70
+ # When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
71
+ # a service returns a successful response without a result schema defined.
72
+ # Failure schemas remain optional regardless of this setting.
73
+ #
74
+ # @return [Boolean] true to require result schemas, false to allow schema-less services
75
+ attr_accessor :require_service_result_schema
76
+
77
+ # Whether to require all event classes to define a payload schema.
78
+ #
79
+ # When enabled, raises {Servus::Support::Errors::SchemaRequiredError} when
80
+ # an event validates a payload without a payload schema defined.
81
+ #
82
+ # @return [Boolean] true to require payload schemas, false to allow schema-less events
83
+ attr_accessor :require_event_payload_schema
84
+
85
+ # The ordered list of routers that resolve invocations for events.
86
+ #
87
+ # The Bus iterates routers in order, collects invocations, deduplicates
88
+ # by key (first wins), and executes. Defaults to +[ClassRouter.new]+
89
+ # which reads +invoke+ declarations from Event classes.
90
+ #
91
+ # @return [Array<Servus::Events::Router>]
92
+ attr_writer :routers
93
+
94
+ # @return [Array<Servus::Events::Router>]
95
+ def routers
96
+ @routers || [Servus::Events::ClassRouter.new]
97
+ end
98
+
99
+ # Whether external instantiation of services is blocked and instance
100
+ # `#call` methods are automatically privatized.
101
+ #
102
+ # When enabled (default), callers must invoke services via the class
103
+ # method {Servus::Base.call}, which runs argument validation, logging,
104
+ # benchmarking, guards, result validation, and event emission. Calling
105
+ # `MyService.new` or `instance.call` directly raises `NoMethodError`.
106
+ #
107
+ # Disable this if you have existing code that instantiates services
108
+ # directly or otherwise prefer to opt out of the enforcement.
109
+ #
110
+ # @return [Boolean] true to enforce lockdown (default), false to allow
111
+ # direct instantiation and public instance `#call`
112
+ # @see Servus::Support::Lockdown
113
+ attr_reader :lockdown_enabled
114
+
115
+ # Sets whether lockdown is enforced, immediately re-applying the
116
+ # resulting `.new` visibility to {Servus::Base}.
117
+ #
118
+ # @param value [Boolean] the new lockdown setting
119
+ # @return [Boolean] the new value
120
+ def lockdown_enabled=(value)
121
+ @lockdown_enabled = value
122
+ Servus::Base.apply_lockdown!
123
+ end
124
+
59
125
  # Initializes a new configuration with default values.
60
126
  #
61
127
  # @api private
62
128
  def initialize
129
+ set_default_directories
130
+ @include_default_guards = true
131
+ @lockdown_enabled = true
132
+ @require_service_arguments_schema = false
133
+ @require_service_result_schema = false
134
+ @require_event_payload_schema = false
135
+ end
136
+
137
+ def set_default_directories
63
138
  @guards_dir = 'app/guards'
64
139
  @events_dir = 'app/events'
65
140
  @schemas_dir = 'app/schemas'
66
141
  @services_dir = 'app/services'
67
-
68
- @strict_event_validation = true
69
- @include_default_guards = true
142
+ @tests_dir = 'spec'
70
143
  end
71
144
 
72
145
  # Returns the full path to a service's schema file.
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ # Base class for event definitions.
5
+ #
6
+ # Event classes live in app/events/ and serve three purposes:
7
+ #
8
+ # 1. *Contract* — declares the event exists and defines its name
9
+ # 2. *Validator* — schema enforcement on any emission
10
+ # 3. *Declarative routing* — optional +invoke+ declarations
11
+ #
12
+ # The event name can be set explicitly via +event_name+ or inferred
13
+ # from the class name (e.g. +OrderPlaced+ becomes +:order_placed+).
14
+ # Call +ensure_registered!+ to trigger inference for classes that
15
+ # don't declare an explicit name.
16
+ #
17
+ # @example Event with explicit name and invoke declarations
18
+ # class UserCreated < Servus::Event
19
+ # event_name :user_created
20
+ #
21
+ # schema payload: { type: 'object', required: ['user_id'] }
22
+ #
23
+ # invoke SendWelcomeEmail::Service, async: true do |payload|
24
+ # { user_id: payload[:user_id] }
25
+ # end
26
+ # end
27
+ #
28
+ # @example Event with inferred name (no invoke — schema-only contract)
29
+ # class OrderPlaced < Servus::Event
30
+ # schema payload: { type: 'object', required: ['order_id'] }
31
+ # end
32
+ #
33
+ # @example Event that passes full payload through (no mapper block)
34
+ # class AuditLogCreated < Servus::Event
35
+ # event_name :audit_log_created
36
+ #
37
+ # invoke AuditLogger::Service, async: true
38
+ # end
39
+ #
40
+ # @see Servus::Events::Bus
41
+ # @see Servus::Events::Router
42
+ # @see Servus::Base
43
+ class Event
44
+ class << self
45
+ # Declares or returns the event name.
46
+ #
47
+ # When called with an argument, sets the event name and registers
48
+ # with the Bus. When called without arguments, returns the current
49
+ # event name.
50
+ #
51
+ # If never called explicitly, use +ensure_registered!+ to infer
52
+ # the name from the class name.
53
+ #
54
+ # @overload event_name(name)
55
+ # @param name [Symbol] the event name to register
56
+ # @return [void]
57
+ # @raise [RuntimeError] if called twice with different names
58
+ #
59
+ # @overload event_name
60
+ # @return [Symbol, nil] the event name or nil if not configured
61
+ #
62
+ # @example Explicit name
63
+ # class UserCreated < Servus::Event
64
+ # event_name :user_created
65
+ # end
66
+ #
67
+ # @example Inferred name (via ensure_registered!)
68
+ # class OrderPlaced < Servus::Event; end
69
+ # OrderPlaced.ensure_registered!
70
+ # OrderPlaced.event_name # => :order_placed
71
+ def event_name(name = nil)
72
+ return @event_name if name.nil?
73
+
74
+ raise "Event already subscribed to :#{@event_name}. Cannot subscribe to :#{name}" if @event_name
75
+
76
+ @event_name = name
77
+ Servus::Events::Bus.register_event(name, self)
78
+ end
79
+
80
+ # Infers and registers the event name from the class name if not
81
+ # already set explicitly. Safe to call multiple times — does
82
+ # nothing if already registered. Skips anonymous classes.
83
+ #
84
+ # @return [void]
85
+ def ensure_registered!
86
+ return if @event_name
87
+ return if name.nil?
88
+
89
+ event_name(name.demodulize.underscore.to_sym)
90
+ end
91
+
92
+ # Declares a service invocation in response to the event.
93
+ #
94
+ # Multiple invocations can be declared for a single event. Each invocation
95
+ # requires a block that maps the event payload to the service's arguments.
96
+ #
97
+ # @param service_class [Class] the service class to invoke (must inherit from Servus::Base)
98
+ # @param options [Hash] invocation options
99
+ # @option options [Boolean] :async invoke the service asynchronously via call_async
100
+ # @option options [Symbol] :queue the queue name for async jobs
101
+ # @option options [Proc] :if condition that must return true for invocation
102
+ # @option options [Proc] :unless condition that must return false for invocation
103
+ # @yield [payload] block that maps event payload to service arguments
104
+ # @yieldparam payload [Hash] the event payload
105
+ # @yieldreturn [Hash] keyword arguments for the service's initialize method
106
+ # @return [void]
107
+ #
108
+ # @example Basic invocation
109
+ # invoke SendEmail::Service do |payload|
110
+ # { user_id: payload[:user_id], email: payload[:email] }
111
+ # end
112
+ #
113
+ # @example Async invocation with queue
114
+ # invoke SendEmail::Service, async: true, queue: :mailers do |payload|
115
+ # { user_id: payload[:user_id] }
116
+ # end
117
+ #
118
+ # @example Conditional invocation
119
+ # invoke GrantRewards::Service, if: ->(p) { p[:premium] } do |payload|
120
+ # { user_id: payload[:user_id] }
121
+ # end
122
+ def invoke(service_class, options = {}, &block)
123
+ @invocations ||= []
124
+ @invocations << {
125
+ service_class: service_class,
126
+ options: options,
127
+ mapper: block || ->(payload) { payload }
128
+ }
129
+ end
130
+
131
+ # Returns all service invocations declared for this event.
132
+ #
133
+ # @return [Array<Hash>] array of invocation configurations
134
+ def invocations
135
+ @invocations || []
136
+ end
137
+
138
+ # Defines the JSON schema for validating event payloads.
139
+ #
140
+ # @param payload [Hash, nil] JSON schema for validating event payloads
141
+ # @return [void]
142
+ #
143
+ # @example
144
+ # class UserCreated < Servus::Event
145
+ # event_name :user_created
146
+ #
147
+ # schema payload: {
148
+ # type: 'object',
149
+ # required: ['user_id', 'email'],
150
+ # properties: {
151
+ # user_id: { type: 'integer' },
152
+ # email: { type: 'string', format: 'email' }
153
+ # }
154
+ # }
155
+ # end
156
+ def schema(payload: nil)
157
+ @payload_schema = payload.with_indifferent_access if payload
158
+ end
159
+
160
+ # Returns the payload schema.
161
+ #
162
+ # @return [Hash, nil] the payload schema or nil if not defined
163
+ # @api private
164
+ attr_reader :payload_schema
165
+
166
+ # Emits this event via the Bus.
167
+ #
168
+ # Provides a type-safe, discoverable way to emit events from anywhere in
169
+ # the application (controllers, jobs, rake tasks) without creating a service.
170
+ #
171
+ # @param payload [Hash] the event payload
172
+ # @return [void]
173
+ # @raise [RuntimeError] if no event name configured
174
+ #
175
+ # @example Emit from controller
176
+ # class UsersController
177
+ # def create
178
+ # user = User.create!(params)
179
+ # UserCreated.emit({ user_id: user.id, email: user.email })
180
+ # redirect_to user
181
+ # end
182
+ # end
183
+ #
184
+ # @example Emit from background job
185
+ # class ProcessDataJob
186
+ # def perform(data_id)
187
+ # result = process_data(data_id)
188
+ # DataProcessed.emit({ data_id: data_id, status: result })
189
+ # end
190
+ # end
191
+ def emit(payload)
192
+ raise 'No event configured. Call event_name :name first.' unless @event_name
193
+
194
+ Servus::Support::Validator.validate_event_payload!(self, payload)
195
+
196
+ Servus::Events::Bus.emit(@event_name, payload)
197
+ end
198
+
199
+ # Returns Invocation objects for the given payload, with conditions
200
+ # already evaluated. This is what routers call to resolve actions.
201
+ #
202
+ # @param payload [Hash] the event payload
203
+ # @return [Array<Servus::Events::Invocation>] invocations that passed conditions
204
+ def invocations_for(payload)
205
+ invocations.filter_map do |inv|
206
+ next unless should_invoke?(payload, inv[:options])
207
+
208
+ Servus::Events::Invocation.new(
209
+ service: inv[:service_class],
210
+ params: inv[:mapper].call(payload),
211
+ options: inv[:options].except(:if, :unless)
212
+ )
213
+ end
214
+ end
215
+
216
+ # Handles an event by resolving and executing all invocations.
217
+ #
218
+ # @param payload [Hash] the event payload
219
+ # @return [Array] results from all invoked services
220
+ def handle(payload)
221
+ invocations_for(payload).map(&:execute)
222
+ end
223
+
224
+ private
225
+
226
+ # @api private
227
+ def should_invoke?(payload, options)
228
+ return false if options[:if] && !options[:if].call(payload)
229
+ return false if options[:unless]&.call(payload)
230
+
231
+ true
232
+ end
233
+ end
234
+ end
235
+ end