servus 0.1.4 → 0.1.6

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/check-docs.md +1 -0
  3. data/.claude/commands/consistency-check.md +1 -0
  4. data/.claude/commands/fine-tooth-comb.md +1 -0
  5. data/.claude/commands/red-green-refactor.md +5 -0
  6. data/.claude/settings.json +15 -0
  7. data/.rubocop.yml +18 -2
  8. data/CHANGELOG.md +46 -0
  9. data/CLAUDE.md +10 -0
  10. data/IDEAS.md +1 -1
  11. data/READme.md +153 -5
  12. data/builds/servus-0.1.4.gem +0 -0
  13. data/builds/servus-0.1.5.gem +0 -0
  14. data/docs/core/2_architecture.md +32 -4
  15. data/docs/current_focus.md +569 -0
  16. data/docs/features/5_event_bus.md +244 -0
  17. data/docs/integration/1_configuration.md +60 -7
  18. data/docs/integration/2_testing.md +123 -0
  19. data/lib/generators/servus/event_handler/event_handler_generator.rb +59 -0
  20. data/lib/generators/servus/event_handler/templates/handler.rb.erb +86 -0
  21. data/lib/generators/servus/event_handler/templates/handler_spec.rb.erb +48 -0
  22. data/lib/generators/servus/service/service_generator.rb +4 -0
  23. data/lib/generators/servus/service/templates/arguments.json.erb +19 -10
  24. data/lib/generators/servus/service/templates/result.json.erb +8 -2
  25. data/lib/generators/servus/service/templates/service.rb.erb +101 -4
  26. data/lib/generators/servus/service/templates/service_spec.rb.erb +67 -6
  27. data/lib/servus/base.rb +21 -5
  28. data/lib/servus/config.rb +34 -14
  29. data/lib/servus/event_handler.rb +275 -0
  30. data/lib/servus/events/bus.rb +137 -0
  31. data/lib/servus/events/emitter.rb +162 -0
  32. data/lib/servus/events/errors.rb +10 -0
  33. data/lib/servus/railtie.rb +16 -0
  34. data/lib/servus/support/validator.rb +27 -0
  35. data/lib/servus/testing/matchers.rb +88 -0
  36. data/lib/servus/testing.rb +2 -0
  37. data/lib/servus/version.rb +1 -1
  38. data/lib/servus.rb +6 -0
  39. metadata +19 -1
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Events
5
+ # Thread-safe event bus for registering and dispatching event handlers.
6
+ #
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.
10
+ #
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
17
+ # end
18
+ #
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) }
24
+ #
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}
28
+ #
29
+ # @see Servus::EventHandler
30
+ class Bus
31
+ class << self
32
+ # Registers a handler class for a specific event.
33
+ #
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.
37
+ #
38
+ # Handlers are typically registered automatically when EventHandler
39
+ # classes are loaded at boot time via the `handles` DSL method.
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
44
+ #
45
+ # @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)
55
+ end
56
+
57
+ # Store subscription for cleanup
58
+ subscriptions[event_name] ||= []
59
+ subscriptions[event_name] << subscription
60
+ end
61
+
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.
66
+ #
67
+ # @param event_name [Symbol] the name of the event
68
+ # @return [Array<Class>] array of handler classes registered for this event
69
+ #
70
+ # @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
75
+ end
76
+
77
+ # Emits an event to all registered handlers with instrumentation.
78
+ #
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.
82
+ #
83
+ # @param event_name [Symbol] the name of the event to emit
84
+ # @param payload [Hash] the event payload to pass to handlers
85
+ # @return [void]
86
+ #
87
+ # @example
88
+ # Bus.emit(:user_created, { user_id: 123, email: 'user@example.com' })
89
+ # # Rails log: servus.events.user_created (1.2ms) {:user_id=>123, :email=>"user@example.com"}
90
+ def emit(event_name, payload)
91
+ ActiveSupport::Notifications.instrument(notification_name(event_name), payload)
92
+ end
93
+
94
+ # Clears all registered handlers and unsubscribes from notifications.
95
+ #
96
+ # Useful for testing and development mode reloading.
97
+ #
98
+ # @return [void]
99
+ #
100
+ # @example
101
+ # Bus.clear
102
+ def clear
103
+ subscriptions.values.flatten.each do |subscription|
104
+ ActiveSupport::Notifications.unsubscribe(subscription)
105
+ end
106
+
107
+ @handlers = nil
108
+ @subscriptions = nil
109
+ end
110
+
111
+ private
112
+
113
+ # Hash storing event handlers.
114
+ #
115
+ # @return [Hash] hash mapping event names to handler arrays
116
+ def handlers
117
+ @handlers ||= {}
118
+ end
119
+
120
+ # Hash storing ActiveSupport::Notifications subscriptions.
121
+ #
122
+ # @return [Hash] hash mapping event names to subscription objects
123
+ def subscriptions
124
+ @subscriptions ||= {}
125
+ end
126
+
127
+ # Converts an event name to a namespaced notification name.
128
+ #
129
+ # @param event_name [Symbol] the event name
130
+ # @return [String] the namespaced notification name
131
+ def notification_name(event_name)
132
+ "servus.events.#{event_name}"
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Events
5
+ # Provides event emission DSL for service objects.
6
+ #
7
+ # This module adds the `emits` class method to services, allowing them to
8
+ # declare events that will be automatically emitted on success, failure, or error.
9
+ #
10
+ # @example Basic usage
11
+ # class CreateUser < Servus::Base
12
+ # emits :user_created, on: :success
13
+ # emits :user_failed, on: :failure
14
+ # end
15
+ module Emitter
16
+ extend ActiveSupport::Concern
17
+
18
+ # Emits events for a service result.
19
+ #
20
+ # Called automatically after service execution completes. Determines the
21
+ # trigger type based on the result and emits all configured events.
22
+ #
23
+ # @param instance [Servus::Base] the service instance
24
+ # @param result [Servus::Support::Response] the service result
25
+ # @return [void]
26
+ # @api private
27
+ def self.emit_result_events!(instance, result)
28
+ trigger = result.success? ? :success : :failure
29
+ instance.send(:emit_events_for, trigger, result)
30
+ end
31
+
32
+ class_methods do
33
+ # Declares an event that this service will emit.
34
+ #
35
+ # Events are automatically emitted when the service completes with the specified
36
+ # trigger condition (:success, :failure, or :error). Use the `with` option to
37
+ # provide a custom payload builder, or pass a block.
38
+ #
39
+ # @param event_name [Symbol] the name of the event to emit
40
+ # @param on [Symbol] when to emit (:success, :failure, or :error)
41
+ # @param with [Symbol, nil] optional instance method name for building the payload
42
+ # @yield [result] optional block for building the payload
43
+ # @yieldparam result [Servus::Support::Response] the service result
44
+ # @yieldreturn [Hash] the event payload
45
+ # @return [void]
46
+ #
47
+ # @example Emit on success with default payload
48
+ # class CreateUser < Servus::Base
49
+ # emits :user_created, on: :success
50
+ # end
51
+ #
52
+ # @example Emit with custom payload builder method
53
+ # class CreateUser < Servus::Base
54
+ # emits :user_created, on: :success, with: :user_payload
55
+ #
56
+ # private
57
+ #
58
+ # def user_payload(result)
59
+ # { user_id: result.data[:user].id }
60
+ # end
61
+ # end
62
+ #
63
+ # @example Emit with custom payload builder block
64
+ # class CreateUser < Servus::Base
65
+ # emits :user_created, on: :success do |result|
66
+ # { user_id: result.data[:user].id }
67
+ # end
68
+ # end
69
+ #
70
+ # @note Best Practice: Services should typically emit ONE event per trigger
71
+ # that represents their core concern. Multiple downstream reactions should
72
+ # be coordinated by EventHandler classes, not by emitting multiple events
73
+ # from the service. This maintains separation of concerns.
74
+ #
75
+ # @example Recommended pattern (one event, multiple handlers)
76
+ # # Service emits one event
77
+ # class CreateUser < Servus::Base
78
+ # emits :user_created, on: :success
79
+ # end
80
+ #
81
+ # # Handler coordinates multiple reactions
82
+ # class UserCreatedHandler < Servus::EventHandler
83
+ # handles :user_created
84
+ # invoke SendWelcomeEmail::Service, async: true
85
+ # invoke TrackAnalytics::Service, async: true
86
+ # end
87
+ #
88
+ # @see Servus::Events::Bus
89
+ # @see Servus::EventHandler
90
+ def emits(event_name, on:, with: nil, &block)
91
+ valid_triggers = %i[success failure error!]
92
+
93
+ unless valid_triggers.include?(on)
94
+ raise ArgumentError, "Invalid trigger: #{on}. Must be one of: #{valid_triggers.join(', ')}"
95
+ end
96
+
97
+ @event_emissions ||= { success: [], failure: [], error!: [] }
98
+ @event_emissions[on] << {
99
+ event_name: event_name,
100
+ payload_builder: block || with
101
+ }
102
+ end
103
+
104
+ # Returns all event emissions declared for this service.
105
+ #
106
+ # @return [Hash] hash of event emissions grouped by trigger
107
+ # { success: [...], failure: [...], error!: [...] }
108
+ def event_emissions
109
+ @event_emissions || { success: [], failure: [], error!: [] }
110
+ end
111
+
112
+ # Returns event emissions for a specific trigger.
113
+ #
114
+ # @param trigger [Symbol] the trigger type (:success, :failure, :error!)
115
+ # @return [Array<Hash>] array of event configurations for this trigger
116
+ def emissions_for(trigger)
117
+ event_emissions[trigger] || []
118
+ end
119
+ end
120
+
121
+ # Emits events for a specific trigger with the given result.
122
+ #
123
+ # @param trigger [Symbol] the trigger type (:success, :failure, :error!)
124
+ # @param result [Servus::Support::Response] the service result
125
+ # @return [void]
126
+ # @api private
127
+ def emit_events_for(trigger, result)
128
+ self.class.emissions_for(trigger).each do |emission|
129
+ payload = build_event_payload(emission, result)
130
+ Servus::Events::Bus.emit(emission[:event_name], payload)
131
+ end
132
+ end
133
+
134
+ # Instance methods for emitting events during service execution
135
+ private
136
+
137
+ # Builds the event payload using the configured payload builder or defaults.
138
+ #
139
+ # @param emission [Hash] the emission configuration
140
+ # @param result [Servus::Support::Response] the service result
141
+ # @return [Hash] the event payload
142
+ # @api private
143
+ def build_event_payload(emission, result)
144
+ builder = emission[:payload_builder]
145
+
146
+ if builder.is_a?(Proc)
147
+ # Block-based payload builder
148
+ builder.call(result)
149
+ elsif builder.is_a?(Symbol)
150
+ # Method-based payload builder
151
+ send(builder, result)
152
+ elsif result.success?
153
+ # Default for success: return data
154
+ result.data
155
+ else
156
+ # Default for failure/error: return error
157
+ result.error
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Servus
4
+ module Events
5
+ # Raised when an event handler subscribes to an event that no service emits.
6
+ #
7
+ # This helps catch typos in event names and orphaned handlers.
8
+ class OrphanedHandlerError < StandardError; end
9
+ end
10
+ end
@@ -18,5 +18,21 @@ module Servus
18
18
  Servus::Base.extend Servus::Extensions::Async::Call
19
19
  end
20
20
  end
21
+
22
+ # Load event handlers and clear on reload
23
+ config.to_prepare do
24
+ Servus::Events::Bus.clear if Rails.env.development?
25
+
26
+ # Eager load all event handlers
27
+ events_path = Rails.root.join(Servus.config.events_dir)
28
+ Dir[File.join(events_path, '**/*_handler.rb')].each do |handler_file|
29
+ require_dependency handler_file
30
+ end
31
+ end
32
+
33
+ # NOTE: Event validation is available but not run automatically due to load order issues.
34
+ # To validate handlers match emitted events, call manually:
35
+ # Servus::EventHandler.validate_all_handlers!
36
+ # Or create a rake task for CI validation.
21
37
  end
22
38
  end
@@ -90,6 +90,33 @@ module Servus
90
90
  result
91
91
  end
92
92
 
93
+ # Validates event payload against the handler's payload schema.
94
+ #
95
+ # @param handler_class [Class] the event handler class
96
+ # @param payload [Hash] the event payload to validate
97
+ # @return [Boolean] true if validation passes
98
+ # @raise [Servus::Support::Errors::ValidationError] if payload fails validation
99
+ #
100
+ #
101
+ # @example
102
+ # Validator.validate_event_payload!(MyEventHandler, { user_id: 123 })
103
+ #
104
+ # @api private
105
+ def self.validate_event_payload!(handler_class, payload)
106
+ schema = handler_class.payload_schema
107
+ return true unless schema
108
+
109
+ serialized_payload = payload.as_json
110
+ validation_errors = JSON::Validator.fully_validate(schema, serialized_payload)
111
+
112
+ if validation_errors.any?
113
+ raise Servus::Support::Errors::ValidationError,
114
+ "Invalid payload for event :#{handler_class.event_name}: #{validation_errors.join(', ')}"
115
+ end
116
+
117
+ true
118
+ end
119
+
93
120
  # Loads and caches a schema for a service.
94
121
  #
95
122
  # Implements a three-tier lookup strategy:
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ require 'rspec/expectations'
5
+
6
+ module Servus
7
+ module Testing
8
+ # RSpec matchers for testing Servus services and events.
9
+ module Matchers
10
+ end
11
+ end
12
+ end
13
+
14
+ # Matcher for asserting event emission
15
+ RSpec::Matchers.define :emit_event do |handler_class_or_symbol|
16
+ supports_block_expectations
17
+
18
+ chain :with do |payload|
19
+ @expected_payload = payload
20
+ end
21
+
22
+ match do |block|
23
+ @captured_events = []
24
+
25
+ subscription = ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, *_args, payload|
26
+ event_name = name.sub('servus.events.', '').to_sym
27
+ @captured_events << { name: event_name, payload: payload }
28
+ end
29
+
30
+ block.call
31
+
32
+ # Determine event name
33
+ @event_name = if handler_class_or_symbol.is_a?(Symbol)
34
+ handler_class_or_symbol
35
+ else
36
+ handler_class_or_symbol.event_name
37
+ end
38
+
39
+ @matching_event = @captured_events.find { |e| e[:name] == @event_name }
40
+
41
+ return false unless @matching_event
42
+ return true unless @expected_payload
43
+
44
+ RSpec::Matchers::BuiltIn::Match.new(@expected_payload).matches?(@matching_event[:payload])
45
+ ensure
46
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
47
+ end
48
+
49
+ failure_message do
50
+ if @matching_event.nil?
51
+ "expected event :#{@event_name} to be emitted, but it was not.\n" \
52
+ "Emitted: #{@captured_events.map { |e| e[:name] }}"
53
+ else
54
+ "expected event :#{@event_name} payload to match #{@expected_payload.inspect}, " \
55
+ "got: #{@matching_event[:payload].inspect}"
56
+ end
57
+ end
58
+ end
59
+
60
+ # Matcher for asserting service invocation
61
+ RSpec::Matchers.define :call_service do |service_class|
62
+ supports_block_expectations
63
+
64
+ chain :with do |args|
65
+ @expected_args = args
66
+ end
67
+
68
+ chain :async do
69
+ @expect_async = true
70
+ end
71
+
72
+ match do |block|
73
+ method_name = @expect_async ? :call_async : :call
74
+
75
+ expectation = expect(service_class).to receive(method_name)
76
+ expectation.with(@expected_args) if @expected_args
77
+
78
+ block.call
79
+
80
+ true
81
+ end
82
+
83
+ failure_message do
84
+ method = @expect_async ? 'call_async' : 'call'
85
+ "expected #{service_class} to receive #{method}"
86
+ end
87
+ end
88
+ # rubocop:enable Metrics/BlockLength
@@ -9,9 +9,11 @@ module Servus
9
9
  #
10
10
  # @see Servus::Testing::ExampleBuilders
11
11
  # @see Servus::Testing::ExampleExtractor
12
+ # @see Servus::Testing::Matchers
12
13
  module Testing
13
14
  end
14
15
  end
15
16
 
16
17
  require_relative 'testing/example_extractor'
17
18
  require_relative 'testing/example_builders'
19
+ require_relative 'testing/matchers'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Servus
4
- VERSION = '0.1.4'
4
+ VERSION = '0.1.6'
5
5
  end
data/lib/servus.rb CHANGED
@@ -25,6 +25,12 @@ require_relative 'servus/support/validator'
25
25
  require_relative 'servus/support/errors'
26
26
  require_relative 'servus/support/rescuer'
27
27
 
28
+ # Events
29
+ require_relative 'servus/events/errors'
30
+ require_relative 'servus/events/bus'
31
+ require_relative 'servus/events/emitter'
32
+ require_relative 'servus/event_handler'
33
+
28
34
  # Core
29
35
  require_relative 'servus/version'
30
36
  require_relative 'servus/base'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: servus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Scholl
@@ -74,10 +74,16 @@ executables: []
74
74
  extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
+ - ".claude/commands/check-docs.md"
78
+ - ".claude/commands/consistency-check.md"
79
+ - ".claude/commands/fine-tooth-comb.md"
80
+ - ".claude/commands/red-green-refactor.md"
81
+ - ".claude/settings.json"
77
82
  - ".rspec"
78
83
  - ".rubocop.yml"
79
84
  - ".yardopts"
80
85
  - CHANGELOG.md
86
+ - CLAUDE.md
81
87
  - IDEAS.md
82
88
  - LICENSE.txt
83
89
  - READme.md
@@ -87,13 +93,16 @@ files:
87
93
  - builds/servus-0.1.2.gem
88
94
  - builds/servus-0.1.3.gem
89
95
  - builds/servus-0.1.4.gem
96
+ - builds/servus-0.1.5.gem
90
97
  - docs/core/1_overview.md
91
98
  - docs/core/2_architecture.md
92
99
  - docs/core/3_service_objects.md
100
+ - docs/current_focus.md
93
101
  - docs/features/1_schema_validation.md
94
102
  - docs/features/2_error_handling.md
95
103
  - docs/features/3_async_execution.md
96
104
  - docs/features/4_logging.md
105
+ - docs/features/5_event_bus.md
97
106
  - docs/guides/1_common_patterns.md
98
107
  - docs/guides/2_migration_guide.md
99
108
  - docs/integration/1_configuration.md
@@ -177,6 +186,9 @@ files:
177
186
  - docs/yard/js/jquery.js
178
187
  - docs/yard/method_list.html
179
188
  - docs/yard/top-level-namespace.html
189
+ - lib/generators/servus/event_handler/event_handler_generator.rb
190
+ - lib/generators/servus/event_handler/templates/handler.rb.erb
191
+ - lib/generators/servus/event_handler/templates/handler_spec.rb.erb
180
192
  - lib/generators/servus/service/service_generator.rb
181
193
  - lib/generators/servus/service/templates/arguments.json.erb
182
194
  - lib/generators/servus/service/templates/result.json.erb
@@ -185,6 +197,10 @@ files:
185
197
  - lib/servus.rb
186
198
  - lib/servus/base.rb
187
199
  - lib/servus/config.rb
200
+ - lib/servus/event_handler.rb
201
+ - lib/servus/events/bus.rb
202
+ - lib/servus/events/emitter.rb
203
+ - lib/servus/events/errors.rb
188
204
  - lib/servus/extensions/async/call.rb
189
205
  - lib/servus/extensions/async/errors.rb
190
206
  - lib/servus/extensions/async/ext.rb
@@ -199,6 +215,7 @@ files:
199
215
  - lib/servus/testing.rb
200
216
  - lib/servus/testing/example_builders.rb
201
217
  - lib/servus/testing/example_extractor.rb
218
+ - lib/servus/testing/matchers.rb
202
219
  - lib/servus/version.rb
203
220
  - sig/servus.rbs
204
221
  homepage: https://github.com/zarpay/servus
@@ -208,6 +225,7 @@ metadata:
208
225
  allowed_push_host: https://rubygems.org
209
226
  source_code_uri: https://github.com/zarpay/servus
210
227
  changelog_uri: https://github.com/zarpay/servus/blob/main/CHANGELOG.md
228
+ rubygems_mfa_required: 'true'
211
229
  rdoc_options: []
212
230
  require_paths:
213
231
  - lib