light-services 2.2.1 → 3.1.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/.github/config/rubocop_linter_action.yml +4 -4
  3. data/.github/workflows/ci.yml +12 -12
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +83 -7
  6. data/CHANGELOG.md +38 -0
  7. data/CLAUDE.md +139 -0
  8. data/Gemfile +16 -11
  9. data/Gemfile.lock +53 -27
  10. data/README.md +84 -21
  11. data/docs/arguments.md +290 -0
  12. data/docs/best-practices.md +153 -0
  13. data/docs/callbacks.md +476 -0
  14. data/docs/concepts.md +80 -0
  15. data/docs/configuration.md +204 -0
  16. data/docs/context.md +128 -0
  17. data/docs/crud.md +525 -0
  18. data/docs/errors.md +280 -0
  19. data/docs/generators.md +250 -0
  20. data/docs/outputs.md +158 -0
  21. data/docs/pundit-authorization.md +320 -0
  22. data/docs/quickstart.md +134 -0
  23. data/docs/readme.md +101 -0
  24. data/docs/recipes.md +14 -0
  25. data/docs/rubocop.md +285 -0
  26. data/docs/ruby-lsp.md +133 -0
  27. data/docs/service-rendering.md +222 -0
  28. data/docs/steps.md +391 -0
  29. data/docs/summary.md +21 -0
  30. data/docs/testing.md +549 -0
  31. data/lib/generators/light_services/install/USAGE +15 -0
  32. data/lib/generators/light_services/install/install_generator.rb +41 -0
  33. data/lib/generators/light_services/install/templates/application_service.rb.tt +8 -0
  34. data/lib/generators/light_services/install/templates/application_service_spec.rb.tt +7 -0
  35. data/lib/generators/light_services/install/templates/initializer.rb.tt +30 -0
  36. data/lib/generators/light_services/service/USAGE +21 -0
  37. data/lib/generators/light_services/service/service_generator.rb +68 -0
  38. data/lib/generators/light_services/service/templates/service.rb.tt +48 -0
  39. data/lib/generators/light_services/service/templates/service_spec.rb.tt +40 -0
  40. data/lib/light/services/base.rb +134 -122
  41. data/lib/light/services/base_with_context.rb +23 -1
  42. data/lib/light/services/callbacks.rb +157 -0
  43. data/lib/light/services/collection.rb +145 -0
  44. data/lib/light/services/concerns/execution.rb +79 -0
  45. data/lib/light/services/concerns/parent_service.rb +34 -0
  46. data/lib/light/services/concerns/state_management.rb +30 -0
  47. data/lib/light/services/config.rb +82 -16
  48. data/lib/light/services/constants.rb +100 -0
  49. data/lib/light/services/dsl/arguments_dsl.rb +85 -0
  50. data/lib/light/services/dsl/outputs_dsl.rb +81 -0
  51. data/lib/light/services/dsl/steps_dsl.rb +205 -0
  52. data/lib/light/services/dsl/validation.rb +162 -0
  53. data/lib/light/services/exceptions.rb +25 -2
  54. data/lib/light/services/message.rb +28 -3
  55. data/lib/light/services/messages.rb +92 -32
  56. data/lib/light/services/rspec/matchers/define_argument.rb +174 -0
  57. data/lib/light/services/rspec/matchers/define_output.rb +147 -0
  58. data/lib/light/services/rspec/matchers/define_step.rb +225 -0
  59. data/lib/light/services/rspec/matchers/execute_step.rb +230 -0
  60. data/lib/light/services/rspec/matchers/have_error_on.rb +148 -0
  61. data/lib/light/services/rspec/matchers/have_warning_on.rb +148 -0
  62. data/lib/light/services/rspec/matchers/trigger_callback.rb +138 -0
  63. data/lib/light/services/rspec.rb +15 -0
  64. data/lib/light/services/rubocop/cop/light_services/argument_type_required.rb +52 -0
  65. data/lib/light/services/rubocop/cop/light_services/condition_method_exists.rb +173 -0
  66. data/lib/light/services/rubocop/cop/light_services/deprecated_methods.rb +113 -0
  67. data/lib/light/services/rubocop/cop/light_services/dsl_order.rb +176 -0
  68. data/lib/light/services/rubocop/cop/light_services/missing_private_keyword.rb +102 -0
  69. data/lib/light/services/rubocop/cop/light_services/no_direct_instantiation.rb +66 -0
  70. data/lib/light/services/rubocop/cop/light_services/output_type_required.rb +52 -0
  71. data/lib/light/services/rubocop/cop/light_services/step_method_exists.rb +109 -0
  72. data/lib/light/services/rubocop.rb +12 -0
  73. data/lib/light/services/settings/field.rb +114 -0
  74. data/lib/light/services/settings/step.rb +53 -20
  75. data/lib/light/services/utils.rb +38 -0
  76. data/lib/light/services/version.rb +1 -1
  77. data/lib/light/services.rb +2 -0
  78. data/lib/ruby_lsp/light_services/addon.rb +36 -0
  79. data/lib/ruby_lsp/light_services/definition.rb +132 -0
  80. data/lib/ruby_lsp/light_services/indexing_enhancement.rb +263 -0
  81. data/light-services.gemspec +6 -8
  82. metadata +68 -26
  83. data/lib/light/services/class_based_collection/base.rb +0 -86
  84. data/lib/light/services/class_based_collection/mount.rb +0 -33
  85. data/lib/light/services/collection/arguments.rb +0 -34
  86. data/lib/light/services/collection/base.rb +0 -59
  87. data/lib/light/services/collection/outputs.rb +0 -16
  88. data/lib/light/services/settings/argument.rb +0 -68
  89. data/lib/light/services/settings/output.rb +0 -34
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module LightServices
6
+ module Generators
7
+ class ServiceGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ argument :name, type: :string, required: true,
11
+ desc: "The name of the service (e.g., user/create or CreateUser)"
12
+
13
+ class_option :args, type: :array, default: [],
14
+ desc: "List of arguments for the service"
15
+ class_option :steps, type: :array, default: [],
16
+ desc: "List of steps for the service"
17
+ class_option :outputs, type: :array, default: [],
18
+ desc: "List of outputs for the service"
19
+ class_option :skip_spec, type: :boolean, default: false,
20
+ desc: "Skip creating the spec file"
21
+ class_option :parent, type: :string, default: "ApplicationService",
22
+ desc: "Parent class for the service"
23
+
24
+ desc "Creates a new service class"
25
+
26
+ def create_service_file
27
+ template "service.rb.tt", "app/services/#{file_path}.rb"
28
+ end
29
+
30
+ def create_spec_file
31
+ return if options[:skip_spec]
32
+ return unless rspec_installed?
33
+
34
+ template "service_spec.rb.tt", "spec/services/#{file_path}_spec.rb"
35
+ end
36
+
37
+ private
38
+
39
+ def file_path
40
+ name.underscore
41
+ end
42
+
43
+ def class_name
44
+ name.camelize
45
+ end
46
+
47
+ def parent_class
48
+ options[:parent]
49
+ end
50
+
51
+ def arguments
52
+ options[:args]
53
+ end
54
+
55
+ def steps
56
+ options[:steps]
57
+ end
58
+
59
+ def outputs
60
+ options[:outputs]
61
+ end
62
+
63
+ def rspec_installed?
64
+ File.directory?(File.join(destination_root, "spec"))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < <%= parent_class %>
4
+ <% if arguments.any? -%>
5
+ # Arguments
6
+ <% arguments.each do |arg| -%>
7
+ arg :<%= arg %>
8
+ <% end -%>
9
+
10
+ <% end -%>
11
+ <% if steps.any? -%>
12
+ # Steps
13
+ <% steps.each do |step| -%>
14
+ step :<%= step %>
15
+ <% end -%>
16
+
17
+ <% end -%>
18
+ <% if outputs.any? -%>
19
+ # Outputs
20
+ <% outputs.each do |output| -%>
21
+ output :<%= output %>
22
+ <% end -%>
23
+
24
+ <% end -%>
25
+ <% if steps.empty? -%>
26
+ # step :step_a
27
+ # step :step_b
28
+
29
+ <% end -%>
30
+ private
31
+
32
+ <% if steps.any? -%>
33
+ <% steps.each_with_index do |step, index| -%>
34
+ def <%= step %>
35
+ # TODO: Implement <%= step %>
36
+ end
37
+ <%= "\n" unless index == steps.length - 1 -%>
38
+ <% end -%>
39
+ <% else -%>
40
+ # def step_a
41
+ # # TODO: Implement service logic
42
+ # end
43
+
44
+ # def step_b
45
+ # # TODO: Implement service logic
46
+ # end
47
+ <% end -%>
48
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+
5
+ # TODO: Add test implementation
6
+ RSpec.describe <%= class_name %>, type: :service do
7
+ <% if arguments.any? -%>
8
+ describe "arguments" do
9
+ <% arguments.each do |arg| -%>
10
+ it { is_expected.to define_argument(:<%= arg %>) }
11
+ <% end -%>
12
+ end
13
+
14
+ <% end -%>
15
+ <% if steps.any? -%>
16
+ describe "steps" do
17
+ <% steps.each do |step| -%>
18
+ it { is_expected.to define_step(:<%= step %>) }
19
+ <% end -%>
20
+ end
21
+
22
+ <% end -%>
23
+ <% if outputs.any? -%>
24
+ describe "outputs" do
25
+ <% outputs.each do |output| -%>
26
+ it { is_expected.to define_output(:<%= output %>) }
27
+ <% end -%>
28
+ end
29
+
30
+ <% end -%>
31
+ describe "#run" do
32
+ subject(:service) { described_class.run(args) }
33
+
34
+ let(:args) { {} }
35
+
36
+ it "succeeds" do
37
+ expect(service).to be_success
38
+ end
39
+ end
40
+ end
@@ -1,116 +1,208 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "benchmark"
4
-
3
+ require "light/services/constants"
5
4
  require "light/services/message"
6
5
  require "light/services/messages"
7
6
  require "light/services/base_with_context"
8
7
 
9
8
  require "light/services/settings/step"
10
- require "light/services/settings/output"
11
- require "light/services/settings/argument"
9
+ require "light/services/settings/field"
10
+
11
+ require "light/services/collection"
12
12
 
13
- require "light/services/collection/base"
14
- require "light/services/collection/outputs"
15
- require "light/services/collection/arguments"
13
+ require "light/services/dsl/arguments_dsl"
14
+ require "light/services/dsl/outputs_dsl"
15
+ require "light/services/dsl/steps_dsl"
16
16
 
17
- require "light/services/class_based_collection/base"
18
- require "light/services/class_based_collection/mount"
17
+ require "light/services/concerns/execution"
18
+ require "light/services/concerns/state_management"
19
+ require "light/services/concerns/parent_service"
19
20
 
20
21
  # Base class for all service objects
21
22
  module Light
22
23
  module Services
24
+ # Base class for building service objects with arguments, outputs, and steps.
25
+ #
26
+ # @example Basic service
27
+ # class CreateUser < Light::Services::Base
28
+ # arg :name, type: String
29
+ # arg :email, type: String
30
+ #
31
+ # output :user, type: User
32
+ #
33
+ # step :create_user
34
+ #
35
+ # private
36
+ #
37
+ # def create_user
38
+ # self.user = User.create!(name: name, email: email)
39
+ # end
40
+ # end
41
+ #
42
+ # result = CreateUser.run(name: "John", email: "john@example.com")
43
+ # result.success? # => true
44
+ # result.user # => #<User id: 1, name: "John">
23
45
  class Base
24
- # Includes
25
- extend ClassBasedCollection::Mount
26
-
27
- # Settings
28
- mount_class_based_collection :steps, item_class: Settings::Step, shortcut: :step
29
- mount_class_based_collection :outputs, item_class: Settings::Output, shortcut: :output
30
- mount_class_based_collection :arguments, item_class: Settings::Argument, shortcut: :arg, allow_redefine: true
31
-
32
- # Arguments
33
- arg :verbose, default: false
34
- arg :benchmark, default: false
35
- arg :deepness, default: 0, context: true
36
-
37
- # Getters
38
- attr_reader :outputs, :arguments, :errors, :warnings
39
-
46
+ include Callbacks
47
+ include Dsl::ArgumentsDsl
48
+ include Dsl::OutputsDsl
49
+ include Dsl::StepsDsl
50
+ include Concerns::Execution
51
+ include Concerns::StateManagement
52
+ include Concerns::ParentService
53
+
54
+ # @return [Collection::Base] collection of output values
55
+ attr_reader :outputs
56
+
57
+ # @return [Collection::Base] collection of argument values
58
+ attr_reader :arguments
59
+
60
+ # @return [Messages] collection of error messages
61
+ attr_reader :errors
62
+
63
+ # @return [Messages] collection of warning messages
64
+ attr_reader :warnings
65
+
66
+ # Initialize a new service instance.
67
+ #
68
+ # @param args [Hash] arguments to pass to the service
69
+ # @param config [Hash] runtime configuration overrides
70
+ # @param parent_service [Base, nil] parent service for nested calls
40
71
  def initialize(args = {}, config = {}, parent_service = nil)
41
72
  @config = Light::Services.config.merge(self.class.class_config || {}).merge(config)
42
73
  @parent_service = parent_service
43
74
 
44
- @outputs = Collection::Outputs.new(self)
45
- @arguments = Collection::Arguments.new(self, args.dup)
75
+ @outputs = Collection::Base.new(self, CollectionTypes::OUTPUTS)
76
+ @arguments = Collection::Base.new(self, CollectionTypes::ARGUMENTS, args.dup)
46
77
 
47
- @done = false
78
+ @stopped = false
48
79
  @launched_steps = []
49
80
 
50
81
  initialize_errors
51
82
  initialize_warnings
52
83
  end
53
84
 
85
+ # Check if the service completed without errors.
86
+ #
87
+ # @return [Boolean] true if no errors were added
54
88
  def success?
55
89
  !errors?
56
90
  end
57
91
 
92
+ # Check if the service completed with errors.
93
+ #
94
+ # @return [Boolean] true if any errors were added
58
95
  def failed?
59
96
  errors?
60
97
  end
61
98
 
99
+ # Check if the service has any errors.
100
+ #
101
+ # @return [Boolean] true if errors collection is not empty
62
102
  def errors?
63
103
  @errors.any?
64
104
  end
65
105
 
106
+ # Check if the service has any warnings.
107
+ #
108
+ # @return [Boolean] true if warnings collection is not empty
66
109
  def warnings?
67
110
  @warnings.any?
68
111
  end
69
112
 
70
- def done!
71
- @done = true
113
+ # Stop executing remaining steps after the current step completes.
114
+ #
115
+ # @return [Boolean] true
116
+ def stop!
117
+ @stopped = true
118
+ end
119
+ alias done! stop!
120
+
121
+ # Check if the service has been stopped.
122
+ #
123
+ # @return [Boolean] true if stop! was called
124
+ def stopped?
125
+ @stopped
72
126
  end
127
+ alias done? stopped?
73
128
 
74
- def done?
75
- @done
129
+ # Stop execution immediately, skipping any remaining code in the current step.
130
+ #
131
+ # @raise [StopExecution] always raises to halt execution
132
+ # @return [void]
133
+ def stop_immediately!
134
+ @stopped = true
135
+ raise Light::Services::StopExecution
76
136
  end
77
137
 
138
+ # Execute the service steps.
139
+ #
140
+ # @return [void]
141
+ # @raise [StandardError] re-raises any exception after running always steps
78
142
  def call
79
143
  load_defaults_and_validate
80
- log_header if benchmark? || verbose?
81
144
 
82
- time = Benchmark.ms do
83
- run_steps
84
- run_steps_with_always
145
+ run_callbacks(:before_service_run, self)
85
146
 
86
- copy_warnings_to_parent_service
87
- copy_errors_to_parent_service
147
+ run_callbacks(:around_service_run, self) do
148
+ execute_service
88
149
  end
89
150
 
90
- return unless benchmark
91
-
92
- log "🟢 Finished #{self.class} in #{time}ms"
93
- puts
151
+ run_service_result_callbacks
94
152
  rescue StandardError => e
95
153
  run_steps_with_always
96
154
  raise e
97
155
  end
98
156
 
99
157
  class << self
158
+ # @return [Hash, nil] class-level configuration options
100
159
  attr_accessor :class_config
101
160
 
161
+ # Set class-level configuration for this service.
162
+ #
163
+ # @param config [Hash] configuration options
164
+ # @return [Hash] the configuration hash
102
165
  def config(config = {})
103
166
  self.class_config = config
104
167
  end
105
168
 
169
+ # Run the service and return the result.
170
+ #
171
+ # @param args [Hash] arguments to pass to the service
172
+ # @param config [Hash] runtime configuration overrides
173
+ # @return [Base] the executed service instance
174
+ #
175
+ # @example
176
+ # result = MyService.run(name: "test")
177
+ # result.success? # => true
106
178
  def run(args = {}, config = {})
107
179
  new(args, config).tap(&:call)
108
180
  end
109
181
 
182
+ # Run the service and raise an error if it fails.
183
+ #
184
+ # @param args [Hash] arguments to pass to the service
185
+ # @param config [Hash] runtime configuration overrides
186
+ # @return [Base] the executed service instance
187
+ # @raise [Error] if the service fails
188
+ #
189
+ # @example
190
+ # MyService.run!(name: "test") # raises if service fails
110
191
  def run!(args = {}, config = {})
111
192
  run(args, config.merge(raise_on_error: true))
112
193
  end
113
194
 
195
+ # Create a context for running the service with a parent service or config.
196
+ #
197
+ # @param service_or_config [Base, Hash] parent service or configuration hash
198
+ # @param config [Hash] configuration hash (when first param is a service)
199
+ # @return [BaseWithContext] context wrapper for running the service
200
+ #
201
+ # @example With parent service
202
+ # ChildService.with(self).run(data: value)
203
+ #
204
+ # @example With configuration
205
+ # MyService.with(use_transactions: false).run(name: "test")
114
206
  def with(service_or_config = {}, config = {})
115
207
  service = service_or_config.is_a?(Hash) ? nil : service_or_config
116
208
  config = service_or_config unless service
@@ -118,86 +210,6 @@ module Light
118
210
  BaseWithContext.new(self, service, config.dup)
119
211
  end
120
212
  end
121
-
122
- # TODO: Add possibility to specify logger
123
- def log(message)
124
- puts "#{' ' * deepness}→ #{message}"
125
- end
126
-
127
- private
128
-
129
- def initialize_errors
130
- @errors = Messages.new(
131
- break_on_add: @config[:break_on_error],
132
- raise_on_add: @config[:raise_on_error],
133
- rollback_on_add: @config[:use_transactions] && @config[:rollback_on_error]
134
- )
135
- end
136
-
137
- def initialize_warnings
138
- @warnings = Messages.new(
139
- break_on_add: @config[:break_on_warning],
140
- raise_on_add: @config[:raise_on_warning],
141
- rollback_on_add: @config[:use_transactions] && @config[:rollback_on_warning]
142
- )
143
- end
144
-
145
- def run_steps
146
- within_transaction do
147
- self.class.steps.each do |name, step|
148
- @launched_steps << name if step.run(self, benchmark: benchmark)
149
-
150
- break if @errors.break? || @warnings.break?
151
- end
152
- end
153
- end
154
-
155
- # Run steps with parameter `always` if they weren't launched because of errors/warnings
156
- def run_steps_with_always
157
- self.class.steps.each do |name, step|
158
- next if !step.always || @launched_steps.include?(name)
159
-
160
- @launched_steps << name if step.run(self)
161
- end
162
- end
163
-
164
- def copy_warnings_to_parent_service
165
- return if !@parent_service || !@config[:load_warnings]
166
-
167
- @parent_service.warnings.copy_from(
168
- @warnings,
169
- break: @config[:self_break_on_warning],
170
- rollback: @config[:self_rollback_on_warning]
171
- )
172
- end
173
-
174
- def copy_errors_to_parent_service
175
- return if !@parent_service || !@config[:load_errors]
176
-
177
- @parent_service.errors.copy_from(
178
- @errors,
179
- break: @config[:self_break_on_error],
180
- rollback: @config[:self_rollback_on_error]
181
- )
182
- end
183
-
184
- def load_defaults_and_validate
185
- @outputs.load_defaults
186
- @arguments.load_defaults
187
- @arguments.validate!
188
- end
189
-
190
- def log_header
191
- log "🏎 Run service #{self.class}"
192
- end
193
-
194
- def within_transaction(&block)
195
- if @config[:use_transactions] && defined?(ActiveRecord::Base)
196
- ActiveRecord::Base.transaction(requires_new: true, &block)
197
- else
198
- yield
199
- end
200
- end
201
213
  end
202
214
  end
203
215
  end
@@ -1,9 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This class allows running a service object with context (parent class and custom config)
4
3
  module Light
5
4
  module Services
5
+ # Wrapper for running a service with a parent context or custom configuration.
6
+ # Created via {Base.with} method.
7
+ #
8
+ # @example Running with parent service context
9
+ # ChildService.with(self).run(data: value)
10
+ #
11
+ # @example Running with custom configuration
12
+ # MyService.with(use_transactions: false).run(name: "test")
6
13
  class BaseWithContext
14
+ # Initialize a new context wrapper.
15
+ #
16
+ # @param service_class [Class] the service class to run
17
+ # @param parent_service [Base, nil] parent service for error/warning propagation
18
+ # @param config [Hash] configuration overrides
19
+ # @raise [ArgTypeError] if parent_service is not a Base subclass
7
20
  def initialize(service_class, parent_service, config)
8
21
  @service_class = service_class
9
22
  @config = config
@@ -14,10 +27,19 @@ module Light
14
27
  raise Light::Services::ArgTypeError, "#{parent_service.class} - must be a subclass of Light::Services::Base"
15
28
  end
16
29
 
30
+ # Run the service with the configured context.
31
+ #
32
+ # @param args [Hash] arguments to pass to the service
33
+ # @return [Base] the executed service instance
17
34
  def run(args = {})
18
35
  @service_class.new(extend_arguments(args), @config, @parent_service).tap(&:call)
19
36
  end
20
37
 
38
+ # Run the service and raise an error if it fails.
39
+ #
40
+ # @param args [Hash] arguments to pass to the service
41
+ # @return [Base] the executed service instance
42
+ # @raise [Error] if the service fails
21
43
  def run!(args = {})
22
44
  @config[:raise_on_error] = true
23
45
  run(args)
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Light
4
+ module Services
5
+ # Provides callback hooks for service and step lifecycle events.
6
+ #
7
+ # @example Service-level callbacks
8
+ # class MyService < Light::Services::Base
9
+ # before_service_run :log_start
10
+ # after_service_run { |service| Rails.logger.info("Done!") }
11
+ # on_service_success :send_notification
12
+ # on_service_failure :log_error
13
+ # end
14
+ #
15
+ # @example Step-level callbacks
16
+ # class MyService < Light::Services::Base
17
+ # before_step_run :log_step_start
18
+ # after_step_run { |service, step_name| puts "Finished #{step_name}" }
19
+ # on_step_failure :handle_step_error
20
+ # end
21
+ #
22
+ # @example Around callbacks
23
+ # class MyService < Light::Services::Base
24
+ # around_service_run :with_timing
25
+ #
26
+ # private
27
+ #
28
+ # def with_timing(service)
29
+ # start = Time.now
30
+ # yield
31
+ # puts "Took #{Time.now - start}s"
32
+ # end
33
+ # end
34
+ module Callbacks
35
+ # Available callback events.
36
+ # @return [Array<Symbol>] list of callback event names
37
+ EVENTS = [
38
+ :before_step_run,
39
+ :after_step_run,
40
+ :around_step_run,
41
+ :on_step_success,
42
+ :on_step_failure,
43
+ :on_step_crash,
44
+ :before_service_run,
45
+ :after_service_run,
46
+ :around_service_run,
47
+ :on_service_success,
48
+ :on_service_failure,
49
+ ].freeze
50
+
51
+ def self.included(base)
52
+ base.extend(ClassMethods)
53
+ end
54
+
55
+ # Class methods for registering callbacks.
56
+ #
57
+ # Each callback event has a corresponding class method:
58
+ # - {before_step_run} - before each step executes
59
+ # - {after_step_run} - after each step executes
60
+ # - {around_step_run} - wraps step execution (must yield)
61
+ # - {on_step_success} - when a step completes without adding errors
62
+ # - {on_step_failure} - when a step adds errors
63
+ # - {on_step_crash} - when a step raises an exception
64
+ # - {before_service_run} - before the service starts
65
+ # - {after_service_run} - after the service completes
66
+ # - {around_service_run} - wraps service execution (must yield)
67
+ # - {on_service_success} - when service completes without errors
68
+ # - {on_service_failure} - when service completes with errors
69
+ module ClassMethods
70
+ # Define DSL methods for each callback event
71
+ EVENTS.each do |event|
72
+ define_method(event) do |method_name = nil, &block|
73
+ callback = method_name || block
74
+ raise ArgumentError, "#{event} requires a method name (symbol) or a block" unless callback
75
+
76
+ unless callback.is_a?(Symbol) || callback.is_a?(Proc)
77
+ raise ArgumentError,
78
+ "#{event} callback must be a Symbol or Proc"
79
+ end
80
+
81
+ callbacks_for(event) << callback
82
+ end
83
+ end
84
+
85
+ # Get callbacks defined in this class for a specific event.
86
+ #
87
+ # @param event [Symbol] the callback event name
88
+ # @return [Array<Symbol, Proc>] callbacks for this event
89
+ def callbacks_for(event)
90
+ @callbacks ||= {}
91
+ @callbacks[event] ||= []
92
+ end
93
+
94
+ # Get all callbacks for an event including inherited ones.
95
+ #
96
+ # @param event [Symbol] the callback event name
97
+ # @return [Array<Symbol, Proc>] all callbacks for this event
98
+ def all_callbacks_for(event)
99
+ if superclass.respond_to?(:all_callbacks_for)
100
+ inherited = superclass.all_callbacks_for(event)
101
+ else
102
+ inherited = []
103
+ end
104
+
105
+ inherited + callbacks_for(event)
106
+ end
107
+ end
108
+
109
+ # Run all callbacks for a given event.
110
+ #
111
+ # @param event [Symbol] the callback event name
112
+ # @param args [Array] arguments to pass to callbacks
113
+ # @yield for around callbacks, the block to wrap
114
+ # @return [void]
115
+ def run_callbacks(event, *args, &block)
116
+ callbacks = self.class.all_callbacks_for(event)
117
+
118
+ if event.to_s.start_with?("around_")
119
+ run_around_callbacks(callbacks, args, &block)
120
+ else
121
+ run_simple_callbacks(callbacks, args)
122
+ yield if block_given?
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def run_simple_callbacks(callbacks, args)
129
+ callbacks.each do |callback|
130
+ execute_callback(callback, args)
131
+ end
132
+ end
133
+
134
+ def run_around_callbacks(callbacks, args, &block)
135
+ return yield if callbacks.empty?
136
+
137
+ # Build a chain of around callbacks
138
+ chain = callbacks.reverse.reduce(block) do |next_block, callback|
139
+ proc { execute_callback(callback, args, &next_block) }
140
+ end
141
+
142
+ chain.call
143
+ end
144
+
145
+ def execute_callback(callback, args, &block)
146
+ case callback
147
+ in Symbol
148
+ block_given? ? send(callback, *args, &block) : send(callback, *args)
149
+ in Proc
150
+ block_given? ? instance_exec(*args, block, &callback) : instance_exec(*args, &callback)
151
+ else
152
+ raise ArgumentError, "Callback must be a Symbol or Proc, got #{callback.class}"
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end