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
@@ -2,96 +2,140 @@
2
2
 
3
3
  module Servus
4
4
  module Events
5
- # Thread-safe event bus for registering and dispatching event handlers.
5
+ # Central event bus for registering Event classes and dispatching
6
+ # events through configured routers.
6
7
  #
7
- # The Bus acts as a central registry that maps event names to their
8
- # corresponding handler classes. It uses ActiveSupport::Notifications
9
- # internally to provide instrumentation and thread-safe event dispatch.
8
+ # The Bus maintains a registry mapping event names to their Event
9
+ # class definitions (one-to-one). On +emit+, it delegates to the
10
+ # configured routers to resolve invocations, deduplicates by key,
11
+ # and executes. ActiveSupport::Notifications wraps the dispatch
12
+ # cycle for the +subscribe_all+ hook (e.g. forwarding to Eventus).
10
13
  #
11
- # Events are automatically instrumented and will appear in Rails logs
12
- # with timing information, making it easy to monitor event performance.
13
- #
14
- # @example Registering a handler
15
- # class UserCreatedHandler < Servus::EventHandler
16
- # handles :user_created
14
+ # @example Registering an event class
15
+ # class UserCreated < Servus::Event
16
+ # event_name :user_created
17
17
  # end
18
+ # # Registration happens automatically via the event_name DSL
18
19
  #
19
- # Servus::Events::Bus.register_handler(:user_created, UserCreatedHandler)
20
- #
21
- # @example Retrieving handlers for an event
22
- # handlers = Servus::Events::Bus.handlers_for(:user_created)
23
- # handlers.each { |handler| handler.handle(payload) }
20
+ # @example Emitting an event
21
+ # Bus.emit(:user_created, { user_id: 123 })
24
22
  #
25
- # @example Instrumentation in logs
26
- # Bus.emit(:user_created, user_id: 123)
27
- # # Rails log: servus.events.user_created (1.2ms) {:user_id=>123}
23
+ # @example Forwarding all events to an external system
24
+ # Bus.subscribe_all do |event_name, payload, started_at:, **|
25
+ # ExternalForwarder.perform_later(event: event_name, payload:)
26
+ # end
28
27
  #
29
- # @see Servus::EventHandler
28
+ # @see Servus::Event
29
+ # @see Servus::Events::Router
30
+ # @see Servus::Events::ClassRouter
30
31
  class Bus
31
32
  class << self
32
- # Registers a handler class for a specific event.
33
+ # Registers an Event class for a specific event name.
33
34
  #
34
- # Multiple handlers can be registered for the same event, and they
35
- # will all be invoked when the event is emitted. The handler is
36
- # automatically subscribed to ActiveSupport::Notifications.
35
+ # Each event name maps to exactly one Event class. Attempting to
36
+ # register a second class for the same name raises an error.
37
37
  #
38
- # Handlers are typically registered automatically when EventHandler
39
- # classes are loaded at boot time via the `handles` DSL method.
38
+ # Event classes are typically registered automatically at boot time
39
+ # via the +event_name+ DSL method or +ensure_registered!+.
40
40
  #
41
- # @param event_name [Symbol] the name of the event
42
- # @param handler_class [Class] the handler class to register
43
- # @return [Array] the updated array of handlers for this event
41
+ # @param name [Symbol] the event name
42
+ # @param event_class [Class] the Event subclass to register
43
+ # @return [void]
44
+ # @raise [RuntimeError] if the event name is already registered
44
45
  #
45
46
  # @example
46
- # Bus.register_handler(:user_created, UserCreatedHandler)
47
- def register_handler(event_name, handler_class)
48
- handlers[event_name] ||= []
49
- handlers[event_name] << handler_class
50
-
51
- # Subscribe to ActiveSupport::Notifications
52
- subscription = ActiveSupport::Notifications.subscribe(notification_name(event_name)) do |*args|
53
- event = ActiveSupport::Notifications::Event.new(*args)
54
- handler_class.handle(event.payload)
47
+ # Bus.register_event(:user_created, UserCreated)
48
+ def register_event(name, event_class)
49
+ if events.key?(name)
50
+ raise "Event :#{name} is already registered to #{events[name]}. Cannot register #{event_class}"
55
51
  end
56
52
 
57
- # Store subscription for cleanup
58
- subscriptions[event_name] ||= []
59
- subscriptions[event_name] << subscription
53
+ events[name] = event_class
60
54
  end
61
55
 
62
- # Retrieves all registered handlers for a specific event.
63
- #
64
- # Returns a duplicate array to prevent external modification of the
65
- # internal handler registry.
56
+ # Returns the Event class registered for the given name.
66
57
  #
67
- # @param event_name [Symbol] the name of the event
68
- # @return [Array<Class>] array of handler classes registered for this event
58
+ # @param name [Symbol] the event name
59
+ # @return [Class, nil] the Event class or nil if not registered
69
60
  #
70
61
  # @example
71
- # handlers = Bus.handlers_for(:user_created)
72
- # handlers.each { |handler| handler.handle(payload) }
73
- def handlers_for(event_name)
74
- (handlers[event_name] || []).dup
62
+ # event_class = Bus.event_for(:user_created)
63
+ # event_class.invocations_for(payload)
64
+ def event_for(name)
65
+ events[name]
75
66
  end
76
67
 
77
- # Emits an event to all registered handlers with instrumentation.
68
+ # Emits an event through the configured routers.
78
69
  #
79
- # Uses ActiveSupport::Notifications to instrument the event, providing
80
- # automatic timing and logging. The event will appear in Rails logs
81
- # with duration and payload information.
70
+ # Collects invocations from all routers (in config array order),
71
+ # deduplicates by key (first wins), and executes each. The entire
72
+ # dispatch is wrapped in ActiveSupport::Notifications so that
73
+ # +subscribe_all+ listeners receive timing information.
82
74
  #
83
75
  # @param event_name [Symbol] the name of the event to emit
84
- # @param payload [Hash] the event payload to pass to handlers
76
+ # @param payload [Hash] the event payload
85
77
  # @return [void]
86
78
  #
87
79
  # @example
88
80
  # Bus.emit(:user_created, { user_id: 123, email: 'user@example.com' })
89
81
  # # Rails log: servus.events.user_created (1.2ms) {:user_id=>123, :email=>"user@example.com"}
90
82
  def emit(event_name, payload)
91
- ActiveSupport::Notifications.instrument(notification_name(event_name), payload)
83
+ ActiveSupport::Notifications.instrument(notification_name(event_name), payload) do
84
+ resolve_invocations(event_name, payload)
85
+ .uniq(&:key)
86
+ .each(&:execute)
87
+ end
88
+ end
89
+
90
+ # Subscribes to all Servus event emissions.
91
+ #
92
+ # Yields the clean event name and payload as positional args, plus
93
+ # +started_at+, +finished_at+, and +id+ as keyword args.
94
+ # Returns the subscription for manual unsubscribe.
95
+ #
96
+ # @yield [event_name, payload, started_at:, finished_at:, id:]
97
+ # @yieldparam event_name [Symbol] the event name
98
+ # @yieldparam payload [Hash] the event payload
99
+ # @yieldparam started_at [Time] when the event was emitted
100
+ # @yieldparam finished_at [Time] when the instrumented block completed
101
+ # @yieldparam id [String] unique notification ID
102
+ # @return [Object] the ActiveSupport::Notifications subscription
103
+ #
104
+ # @example Forward all events to an external system
105
+ # Servus::Events::Bus.subscribe_all do |event_name, payload, started_at:, **|
106
+ # EventusForwardJob.perform_later(
107
+ # event: event_name.to_s,
108
+ # payload: payload.as_json,
109
+ # occurred_at: started_at.utc.iso8601(6)
110
+ # )
111
+ # end
112
+ def subscribe_all(&block)
113
+ ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, started, finished, id, payload|
114
+ event_name = name.delete_prefix('servus.events.').to_sym
115
+ block.call(event_name, payload, started_at: started, finished_at: finished, id: id)
116
+ end
117
+ end
118
+
119
+ # Installs the internal event logger. Called once at boot via
120
+ # the Railtie. Logs every event emission with its AS::Notifications
121
+ # correlation ID and dispatch duration.
122
+ #
123
+ # Multiple +subscribe_all+ subscriptions coexist — the app can
124
+ # add its own (e.g. for Eventus forwarding) independently.
125
+ #
126
+ # @return [void]
127
+ def enable_logging!
128
+ return if @logging_enabled
129
+
130
+ subscribe_all do |event_name, payload, **meta|
131
+ duration_ms = (meta[:finished_at] - meta[:started_at]) * 1000
132
+ Servus::Support::Logger.log_event(event_name, payload, event_id: meta[:id], duration_ms:)
133
+ end
134
+
135
+ @logging_enabled = true
92
136
  end
93
137
 
94
- # Clears all registered handlers and unsubscribes from notifications.
138
+ # Clears all registered events.
95
139
  #
96
140
  # Useful for testing and development mode reloading.
97
141
  #
@@ -100,28 +144,23 @@ module Servus
100
144
  # @example
101
145
  # Bus.clear
102
146
  def clear
103
- subscriptions.values.flatten.each do |subscription|
104
- ActiveSupport::Notifications.unsubscribe(subscription)
105
- end
106
-
107
- @handlers = nil
108
- @subscriptions = nil
147
+ @events = nil
109
148
  end
110
149
 
111
150
  private
112
151
 
113
- # Hash storing event handlers.
152
+ # Collects invocations from all configured routers.
114
153
  #
115
- # @return [Hash] hash mapping event names to handler arrays
116
- def handlers
117
- @handlers ||= {}
154
+ # @param event_name [Symbol] the event name
155
+ # @param payload [Hash] the event payload
156
+ # @return [Array<Servus::Events::Invocation>]
157
+ def resolve_invocations(event_name, payload)
158
+ Servus.config.routers.flat_map { |router| router.resolve(event_name, payload) }
118
159
  end
119
160
 
120
- # Hash storing ActiveSupport::Notifications subscriptions.
121
- #
122
- # @return [Hash] hash mapping event names to subscription objects
123
- def subscriptions
124
- @subscriptions ||= {}
161
+ # @return [Hash{Symbol => Class}] event name to Event class mapping
162
+ def events
163
+ @events ||= {}
125
164
  end
126
165
 
127
166
  # Converts an event name to a namespaced notification name.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Events
5
+ # Default router that reads +invoke+ declarations from Event classes.
6
+ #
7
+ # ClassRouter is what ships with Servus and is the default when no
8
+ # routers are configured. It resolves invocations by looking up all
9
+ # Event classes registered for the given event name and calling
10
+ # +invocations_for+ on each — which evaluates if/unless conditions
11
+ # and returns Invocation objects for actions that should run.
12
+ #
13
+ # Applications can add additional routers (e.g. a data-driven router
14
+ # backed by a database table) alongside the ClassRouter:
15
+ #
16
+ # Servus.configure do |config|
17
+ # config.routers = [
18
+ # Servus::Events::ClassRouter.new,
19
+ # MyApp::DataDrivenRouter.new
20
+ # ]
21
+ # end
22
+ #
23
+ # @see Servus::Events::Router
24
+ # @see Servus::Event#invocations_for
25
+ class ClassRouter < Router
26
+ # Resolves invocations by reading +invoke+ declarations from all
27
+ # Event classes registered for the given event name.
28
+ #
29
+ # @param event_name [Symbol] the name of the emitted event
30
+ # @param payload [Hash] the event payload
31
+ # @return [Array<Servus::Events::Invocation>] invocations to execute
32
+ def resolve(event_name, payload)
33
+ event_class = Bus.event_for(event_name)
34
+ return [] unless event_class
35
+
36
+ event_class.invocations_for(payload)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -69,24 +69,24 @@ module Servus
69
69
  #
70
70
  # @note Best Practice: Services should typically emit ONE event per trigger
71
71
  # that represents their core concern. Multiple downstream reactions should
72
- # be coordinated by EventHandler classes, not by emitting multiple events
72
+ # be coordinated by Event classes, not by emitting multiple events
73
73
  # from the service. This maintains separation of concerns.
74
74
  #
75
- # @example Recommended pattern (one event, multiple handlers)
75
+ # @example Recommended pattern (one event, multiple reactions)
76
76
  # # Service emits one event
77
77
  # class CreateUser < Servus::Base
78
78
  # emits :user_created, on: :success
79
79
  # end
80
80
  #
81
- # # Handler coordinates multiple reactions
82
- # class UserCreatedHandler < Servus::EventHandler
83
- # handles :user_created
81
+ # # Event coordinates multiple reactions
82
+ # class UserCreated < Servus::Event
83
+ # event_name :user_created
84
84
  # invoke SendWelcomeEmail::Service, async: true
85
85
  # invoke TrackAnalytics::Service, async: true
86
86
  # end
87
87
  #
88
88
  # @see Servus::Events::Bus
89
- # @see Servus::EventHandler
89
+ # @see Servus::Event
90
90
  def emits(event_name, on:, with: nil, &block)
91
91
  valid_triggers = %i[success failure error!]
92
92
 
@@ -127,6 +127,7 @@ 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)
130
131
  Servus::Events::Bus.emit(emission[:event_name], payload)
131
132
  end
132
133
  end
@@ -134,6 +135,20 @@ module Servus
134
135
  # Instance methods for emitting events during service execution
135
136
  private
136
137
 
138
+ # Validates the payload against the Event class's schema registered for the event.
139
+ #
140
+ # @param event_name [Symbol] the event name
141
+ # @param payload [Hash] the event payload
142
+ # @return [void]
143
+ # @raise [Servus::Support::Errors::ValidationError] if payload fails validation
144
+ # @api private
145
+ def validate_event_payload!(event_name, payload)
146
+ event_class = Servus::Events::Bus.event_for(event_name)
147
+ return unless event_class
148
+
149
+ Servus::Support::Validator.validate_event_payload!(event_class, payload)
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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Servus
7
+ module Events
8
+ # A normalized, executable representation of "call this service with
9
+ # these params."
10
+ #
11
+ # Routers return arrays of Invocation objects. The Bus collects them,
12
+ # deduplicates by +#key+ (first wins), and calls +#execute+ on each.
13
+ #
14
+ # An Invocation separates *identity* (service + params) from
15
+ # *execution strategy* (async, queue, priority, etc.). The +#key+
16
+ # is derived only from the identity — two invocations that call the
17
+ # same service with the same params are considered duplicates
18
+ # regardless of their options.
19
+ #
20
+ # @example Sync invocation
21
+ # Invocation.new(
22
+ # service: Rewards::Grant::Service,
23
+ # params: { user_id: "abc-123" },
24
+ # options: {}
25
+ # )
26
+ #
27
+ # @example Async invocation with scheduling options
28
+ # Invocation.new(
29
+ # service: Notifications::Send::Service,
30
+ # params: { user_id: "abc-123" },
31
+ # options: { async: true, queue: :mailers, priority: 5 }
32
+ # )
33
+ #
34
+ # @see Servus::Events::Router
35
+ # @see Servus::Events::Bus
36
+ class Invocation
37
+ # @return [Class] the service class to call (must respond to +.call+ or +.call_async+)
38
+ attr_reader :service
39
+
40
+ # @return [Hash] keyword arguments passed to the service
41
+ attr_reader :params
42
+
43
+ # @return [Hash] execution options — +async+, +queue+, +wait+,
44
+ # +wait_until+, +priority+, +job_options+
45
+ attr_reader :options
46
+
47
+ # @param service [Class] the service class
48
+ # @param params [Hash] keyword arguments for the service
49
+ # @param options [Hash] execution options
50
+ def initialize(service:, params:, options: {})
51
+ @service = service
52
+ @params = params
53
+ @options = options
54
+ end
55
+
56
+ # Executes the invocation.
57
+ #
58
+ # Delegates to +service.call+ for synchronous invocations or
59
+ # +service.call_async+ for asynchronous ones. Async scheduling
60
+ # options (queue, wait, priority, etc.) are merged into the
61
+ # call_async kwargs.
62
+ #
63
+ # @return [Servus::Support::Response, void]
64
+ def execute
65
+ if options[:async]
66
+ service.call_async(**params, **async_options)
67
+ else
68
+ service.call(**params)
69
+ end
70
+ end
71
+
72
+ # A deterministic deduplication key derived from the service class
73
+ # and params. Two invocations with the same key are considered
74
+ # duplicates — the Bus keeps the first and skips the rest.
75
+ #
76
+ # Options are intentionally excluded: identity is *what* to call,
77
+ # not *how* to call it.
78
+ #
79
+ # @return [String] SHA-256 hex digest
80
+ def key
81
+ Digest::SHA256.hexdigest("#{service}:#{params.to_json}")
82
+ end
83
+
84
+ private
85
+
86
+ # Extracts scheduling options for +call_async+.
87
+ #
88
+ # @return [Hash]
89
+ def async_options
90
+ options.slice(:queue, :wait, :wait_until, :priority, :job_options).compact
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Events
5
+ # Abstract base class for event routers.
6
+ #
7
+ # Routers resolve which services should be invoked when an event fires.
8
+ # The Bus iterates all configured routers (in order), collects the
9
+ # Invocation objects they return, deduplicates by key, and executes.
10
+ #
11
+ # Servus ships one built-in router — ClassRouter — which reads the
12
+ # +invoke+ declarations from Event classes. Applications can add their
13
+ # own routers (e.g. a data-driven router backed by a database table)
14
+ # by subclassing Router and implementing +#resolve+.
15
+ #
16
+ # Configure routers in the Servus initializer:
17
+ #
18
+ # Servus.configure do |config|
19
+ # config.routers = [
20
+ # Servus::Events::ClassRouter.new,
21
+ # MyApp::DataDrivenRouter.new
22
+ # ]
23
+ # end
24
+ #
25
+ # @see Servus::Events::ClassRouter
26
+ # @see Servus::Events::Invocation
27
+ # @see Servus::Events::Bus
28
+ class Router
29
+ # Resolves which service invocations should run for the given event.
30
+ #
31
+ # Implementations must evaluate any conditions (if/unless) internally
32
+ # and return only invocations that *will* run. The Bus does not
33
+ # perform further filtering.
34
+ #
35
+ # @param event_name [Symbol] the name of the emitted event
36
+ # @param payload [Hash] the event payload
37
+ # @return [Array<Servus::Events::Invocation>] invocations to execute
38
+ # @raise [NotImplementedError] when called on the abstract base class
39
+ def resolve(event_name, payload)
40
+ raise NotImplementedError, "#{self.class}#resolve must be implemented"
41
+ end
42
+ end
43
+ end
44
+ 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
 
@@ -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