operandi 5.0.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.
- checksums.yaml +7 -0
- data/.cursor/rules/services/RULE.md +269 -0
- data/.cursor/rules/services-rspec/RULE.md +354 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.rubocop.yml +134 -0
- data/.ruby-version +1 -0
- data/.vscode/cspell.json +18 -0
- data/.vscode/project-words.txt +12 -0
- data/AGENTS.md +139 -0
- data/CHANGELOG.md +111 -0
- data/CLAUDE.md +139 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +28 -0
- data/Gemfile.lock +149 -0
- data/LICENSE.txt +21 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/default.yml +57 -0
- data/docs/README.md +105 -0
- data/docs/SUMMARY.md +31 -0
- data/docs/arguments.md +275 -0
- data/docs/best-practices.md +153 -0
- data/docs/callbacks.md +475 -0
- data/docs/concepts.md +79 -0
- data/docs/configuration.md +218 -0
- data/docs/context.md +128 -0
- data/docs/crud.md +525 -0
- data/docs/errors.md +331 -0
- data/docs/generators.md +250 -0
- data/docs/outputs.md +150 -0
- data/docs/pundit-authorization.md +320 -0
- data/docs/quickstart.md +133 -0
- data/docs/recipes.md +14 -0
- data/docs/rubocop.md +430 -0
- data/docs/ruby-lsp.md +121 -0
- data/docs/service-rendering.md +222 -0
- data/docs/sorbet-runtime.md +283 -0
- data/docs/steps.md +438 -0
- data/docs/tapioca.md +188 -0
- data/docs/testing.md +548 -0
- data/lib/generators/operandi/install/USAGE +15 -0
- data/lib/generators/operandi/install/install_generator.rb +45 -0
- data/lib/generators/operandi/install/templates/application_service.rb.tt +8 -0
- data/lib/generators/operandi/install/templates/application_service_spec.rb.tt +7 -0
- data/lib/generators/operandi/install/templates/initializer.rb.tt +30 -0
- data/lib/generators/operandi/service/USAGE +21 -0
- data/lib/generators/operandi/service/service_generator.rb +80 -0
- data/lib/generators/operandi/service/templates/service.rb.tt +48 -0
- data/lib/generators/operandi/service/templates/service_spec.rb.tt +40 -0
- data/lib/operandi/base.rb +230 -0
- data/lib/operandi/base_with_context.rb +57 -0
- data/lib/operandi/callbacks.rb +353 -0
- data/lib/operandi/collection.rb +166 -0
- data/lib/operandi/concerns/execution.rb +80 -0
- data/lib/operandi/concerns/parent_service.rb +32 -0
- data/lib/operandi/concerns/state_management.rb +34 -0
- data/lib/operandi/config.rb +142 -0
- data/lib/operandi/constants.rb +96 -0
- data/lib/operandi/dsl/arguments_dsl.rb +83 -0
- data/lib/operandi/dsl/outputs_dsl.rb +79 -0
- data/lib/operandi/dsl/steps_dsl.rb +206 -0
- data/lib/operandi/dsl/validation.rb +171 -0
- data/lib/operandi/exceptions.rb +66 -0
- data/lib/operandi/message.rb +52 -0
- data/lib/operandi/messages.rb +185 -0
- data/lib/operandi/rspec/matchers/define_argument.rb +172 -0
- data/lib/operandi/rspec/matchers/define_output.rb +145 -0
- data/lib/operandi/rspec/matchers/define_step.rb +223 -0
- data/lib/operandi/rspec/matchers/execute_step.rb +228 -0
- data/lib/operandi/rspec/matchers/have_error_on.rb +144 -0
- data/lib/operandi/rspec/matchers/have_warning_on.rb +146 -0
- data/lib/operandi/rspec/matchers/trigger_callback.rb +136 -0
- data/lib/operandi/rspec.rb +15 -0
- data/lib/operandi/rubocop/cop/operandi/argument_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/condition_method_exists.rb +173 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_accessors.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/deprecated_methods.rb +113 -0
- data/lib/operandi/rubocop/cop/operandi/dsl_order.rb +181 -0
- data/lib/operandi/rubocop/cop/operandi/missing_private_keyword.rb +102 -0
- data/lib/operandi/rubocop/cop/operandi/no_direct_instantiation.rb +66 -0
- data/lib/operandi/rubocop/cop/operandi/no_hash_argument.rb +101 -0
- data/lib/operandi/rubocop/cop/operandi/output_type_required.rb +52 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_fail_method.rb +112 -0
- data/lib/operandi/rubocop/cop/operandi/prefer_optional_over_default_nil.rb +124 -0
- data/lib/operandi/rubocop/cop/operandi/redundant_optional.rb +103 -0
- data/lib/operandi/rubocop/cop/operandi/reserved_name.rb +56 -0
- data/lib/operandi/rubocop/cop/operandi/step_method_exists.rb +134 -0
- data/lib/operandi/rubocop.rb +22 -0
- data/lib/operandi/settings/field.rb +147 -0
- data/lib/operandi/settings/step.rb +105 -0
- data/lib/operandi/utils.rb +36 -0
- data/lib/operandi/version.rb +5 -0
- data/lib/operandi.rb +11 -0
- data/lib/ruby_lsp/operandi/addon.rb +37 -0
- data/lib/ruby_lsp/operandi/definition.rb +134 -0
- data/lib/ruby_lsp/operandi/indexing_enhancement.rb +224 -0
- data/lib/tapioca/dsl/compilers/operandi.rb +378 -0
- data/operandi.gemspec +33 -0
- data/rbi/operandi.rbi +197 -0
- data/sorbet/cache/data.mdb +0 -0
- data/sorbet/cache/lock.mdb +0 -0
- metadata +151 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
# Provides callback hooks for service and step lifecycle events.
|
|
5
|
+
#
|
|
6
|
+
# @example Service-level callbacks
|
|
7
|
+
# class MyService < Operandi::Base
|
|
8
|
+
# before_service_run :log_start
|
|
9
|
+
# after_service_run { |service| Rails.logger.info("Done!") }
|
|
10
|
+
# on_service_success :send_notification
|
|
11
|
+
# on_service_failure :log_error
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Step-level callbacks
|
|
15
|
+
# class MyService < Operandi::Base
|
|
16
|
+
# before_step_run :log_step_start
|
|
17
|
+
# after_step_run { |service, step_name| puts "Finished #{step_name}" }
|
|
18
|
+
# on_step_failure :handle_step_error
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Around callbacks
|
|
22
|
+
# class MyService < Operandi::Base
|
|
23
|
+
# around_service_run :with_timing
|
|
24
|
+
#
|
|
25
|
+
# private
|
|
26
|
+
#
|
|
27
|
+
# def with_timing(service)
|
|
28
|
+
# start = Time.now
|
|
29
|
+
# yield
|
|
30
|
+
# puts "Took #{Time.now - start}s"
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
module Callbacks
|
|
34
|
+
# Available callback events.
|
|
35
|
+
# @return [Array<Symbol>] list of callback event names
|
|
36
|
+
EVENTS = [
|
|
37
|
+
:before_step_run,
|
|
38
|
+
:after_step_run,
|
|
39
|
+
:around_step_run,
|
|
40
|
+
:on_step_success,
|
|
41
|
+
:on_step_failure,
|
|
42
|
+
:on_step_crash,
|
|
43
|
+
:before_service_run,
|
|
44
|
+
:after_service_run,
|
|
45
|
+
:around_service_run,
|
|
46
|
+
:on_service_success,
|
|
47
|
+
:on_service_failure,
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
# Run all callbacks for a given event.
|
|
51
|
+
#
|
|
52
|
+
# @param event [Symbol] the callback event name
|
|
53
|
+
# @param args [Array] arguments to pass to callbacks
|
|
54
|
+
# @yield for around callbacks, the block to wrap
|
|
55
|
+
# @return [void]
|
|
56
|
+
def run_callbacks(event, *args, &)
|
|
57
|
+
callbacks = self.class.all_callbacks_for(event)
|
|
58
|
+
|
|
59
|
+
if event.to_s.start_with?("around_")
|
|
60
|
+
run_around_callbacks(callbacks, args, &)
|
|
61
|
+
else
|
|
62
|
+
run_simple_callbacks(callbacks, args)
|
|
63
|
+
yield if block_given?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def run_simple_callbacks(callbacks, args)
|
|
70
|
+
callbacks.each do |callback|
|
|
71
|
+
execute_callback(callback, args)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run_around_callbacks(callbacks, args, &block)
|
|
76
|
+
return yield if callbacks.empty?
|
|
77
|
+
|
|
78
|
+
# Build a chain of around callbacks
|
|
79
|
+
chain = callbacks.reverse.reduce(block) do |next_block, callback|
|
|
80
|
+
proc { execute_callback(callback, args, &next_block) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
chain.call
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def execute_callback(callback, args, &block)
|
|
87
|
+
case callback
|
|
88
|
+
in Symbol
|
|
89
|
+
block_given? ? send(callback, *args, &block) : send(callback, *args)
|
|
90
|
+
in Proc
|
|
91
|
+
block_given? ? instance_exec(*args, block, &callback) : instance_exec(*args, &callback)
|
|
92
|
+
else
|
|
93
|
+
raise ArgumentError, "Callback must be a Symbol or Proc, got #{callback.class}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Class methods for registering callbacks.
|
|
99
|
+
# Extend this module in your service class to get callback DSL methods.
|
|
100
|
+
#
|
|
101
|
+
# @example
|
|
102
|
+
# class MyService < Operandi::Base
|
|
103
|
+
# before_service_run :setup
|
|
104
|
+
# after_service_run :cleanup
|
|
105
|
+
# end
|
|
106
|
+
module CallbackDsl
|
|
107
|
+
# Registers a callback to run before each step executes.
|
|
108
|
+
#
|
|
109
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
110
|
+
# @yield [service, step_name] block to execute if no method name provided
|
|
111
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
112
|
+
# @yieldparam step_name [Symbol] the name of the step about to run
|
|
113
|
+
# @return [void]
|
|
114
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
115
|
+
#
|
|
116
|
+
# @example With method name
|
|
117
|
+
# before_step_run :log_step_start
|
|
118
|
+
#
|
|
119
|
+
# @example With block
|
|
120
|
+
# before_step_run { |service, step_name| puts "Starting #{step_name}" }
|
|
121
|
+
def before_step_run(method_name = nil, &)
|
|
122
|
+
register_callback(:before_step_run, method_name, &)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Registers a callback to run after each step executes.
|
|
126
|
+
#
|
|
127
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
128
|
+
# @yield [service, step_name] block to execute if no method name provided
|
|
129
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
130
|
+
# @yieldparam step_name [Symbol] the name of the step that just ran
|
|
131
|
+
# @return [void]
|
|
132
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
133
|
+
#
|
|
134
|
+
# @example With method name
|
|
135
|
+
# after_step_run :log_step_complete
|
|
136
|
+
#
|
|
137
|
+
# @example With block
|
|
138
|
+
# after_step_run { |service, step_name| puts "Finished #{step_name}" }
|
|
139
|
+
def after_step_run(method_name = nil, &)
|
|
140
|
+
register_callback(:after_step_run, method_name, &)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Registers an around callback that wraps each step execution.
|
|
144
|
+
# The callback must yield to execute the step.
|
|
145
|
+
#
|
|
146
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
147
|
+
# @yield [service, step_name] block to execute if no method name provided
|
|
148
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
149
|
+
# @yieldparam step_name [Symbol] the name of the step being wrapped
|
|
150
|
+
# @return [void]
|
|
151
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
152
|
+
#
|
|
153
|
+
# @example With method name
|
|
154
|
+
# around_step_run :with_step_timing
|
|
155
|
+
#
|
|
156
|
+
# def with_step_timing(service, step_name)
|
|
157
|
+
# start = Time.now
|
|
158
|
+
# yield
|
|
159
|
+
# puts "#{step_name} took #{Time.now - start}s"
|
|
160
|
+
# end
|
|
161
|
+
def around_step_run(method_name = nil, &)
|
|
162
|
+
register_callback(:around_step_run, method_name, &)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Registers a callback to run when a step completes successfully (without adding errors).
|
|
166
|
+
#
|
|
167
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
168
|
+
# @yield [service, step_name] block to execute if no method name provided
|
|
169
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
170
|
+
# @yieldparam step_name [Symbol] the name of the successful step
|
|
171
|
+
# @return [void]
|
|
172
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
173
|
+
#
|
|
174
|
+
# @example With method name
|
|
175
|
+
# on_step_success :track_step_success
|
|
176
|
+
#
|
|
177
|
+
# @example With block
|
|
178
|
+
# on_step_success { |service, step_name| Analytics.track("step.success", step: step_name) }
|
|
179
|
+
def on_step_success(method_name = nil, &)
|
|
180
|
+
register_callback(:on_step_success, method_name, &)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Registers a callback to run when a step fails (adds errors).
|
|
184
|
+
#
|
|
185
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
186
|
+
# @yield [service, step_name] block to execute if no method name provided
|
|
187
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
188
|
+
# @yieldparam step_name [Symbol] the name of the failed step
|
|
189
|
+
# @return [void]
|
|
190
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
191
|
+
#
|
|
192
|
+
# @example With method name
|
|
193
|
+
# on_step_failure :handle_step_error
|
|
194
|
+
#
|
|
195
|
+
# @example With block
|
|
196
|
+
# on_step_failure { |service, step_name| Rails.logger.error("Step #{step_name} failed") }
|
|
197
|
+
def on_step_failure(method_name = nil, &)
|
|
198
|
+
register_callback(:on_step_failure, method_name, &)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Registers a callback to run when a step raises an exception.
|
|
202
|
+
#
|
|
203
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
204
|
+
# @yield [service, step_name, exception] block to execute if no method name provided
|
|
205
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
206
|
+
# @yieldparam step_name [Symbol] the name of the crashed step
|
|
207
|
+
# @yieldparam exception [Exception] the exception that was raised
|
|
208
|
+
# @return [void]
|
|
209
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
210
|
+
#
|
|
211
|
+
# @example With method name
|
|
212
|
+
# on_step_crash :report_crash
|
|
213
|
+
#
|
|
214
|
+
# @example With block
|
|
215
|
+
# on_step_crash { |service, step_name, error| Sentry.capture_exception(error) }
|
|
216
|
+
def on_step_crash(method_name = nil, &)
|
|
217
|
+
register_callback(:on_step_crash, method_name, &)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Registers a callback to run before the service starts executing.
|
|
221
|
+
#
|
|
222
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
223
|
+
# @yield [service] block to execute if no method name provided
|
|
224
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
225
|
+
# @return [void]
|
|
226
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
227
|
+
#
|
|
228
|
+
# @example With method name
|
|
229
|
+
# before_service_run :log_start
|
|
230
|
+
#
|
|
231
|
+
# @example With block
|
|
232
|
+
# before_service_run { |service| Rails.logger.info("Starting #{service.class.name}") }
|
|
233
|
+
def before_service_run(method_name = nil, &)
|
|
234
|
+
register_callback(:before_service_run, method_name, &)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Registers a callback to run after the service completes (regardless of success/failure).
|
|
238
|
+
#
|
|
239
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
240
|
+
# @yield [service] block to execute if no method name provided
|
|
241
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
242
|
+
# @return [void]
|
|
243
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
244
|
+
#
|
|
245
|
+
# @example With method name
|
|
246
|
+
# after_service_run :cleanup
|
|
247
|
+
#
|
|
248
|
+
# @example With block
|
|
249
|
+
# after_service_run { |service| Rails.logger.info("Done!") }
|
|
250
|
+
def after_service_run(method_name = nil, &)
|
|
251
|
+
register_callback(:after_service_run, method_name, &)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Registers an around callback that wraps the entire service execution.
|
|
255
|
+
# The callback must yield to execute the service.
|
|
256
|
+
#
|
|
257
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
258
|
+
# @yield [service] block to execute if no method name provided
|
|
259
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
260
|
+
# @return [void]
|
|
261
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
262
|
+
#
|
|
263
|
+
# @example With method name
|
|
264
|
+
# around_service_run :with_timing
|
|
265
|
+
#
|
|
266
|
+
# def with_timing(service)
|
|
267
|
+
# start = Time.now
|
|
268
|
+
# yield
|
|
269
|
+
# puts "Took #{Time.now - start}s"
|
|
270
|
+
# end
|
|
271
|
+
def around_service_run(method_name = nil, &)
|
|
272
|
+
register_callback(:around_service_run, method_name, &)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Registers a callback to run when the service completes successfully (without errors).
|
|
276
|
+
#
|
|
277
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
278
|
+
# @yield [service] block to execute if no method name provided
|
|
279
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
280
|
+
# @return [void]
|
|
281
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
282
|
+
#
|
|
283
|
+
# @example With method name
|
|
284
|
+
# on_service_success :send_notification
|
|
285
|
+
#
|
|
286
|
+
# @example With block
|
|
287
|
+
# on_service_success { |service| NotificationMailer.success(service.user).deliver_later }
|
|
288
|
+
def on_service_success(method_name = nil, &)
|
|
289
|
+
register_callback(:on_service_success, method_name, &)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Registers a callback to run when the service completes with errors.
|
|
293
|
+
#
|
|
294
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
295
|
+
# @yield [service] block to execute if no method name provided
|
|
296
|
+
# @yieldparam service [Operandi::Base] the service instance
|
|
297
|
+
# @return [void]
|
|
298
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
299
|
+
#
|
|
300
|
+
# @example With method name
|
|
301
|
+
# on_service_failure :log_error
|
|
302
|
+
#
|
|
303
|
+
# @example With block
|
|
304
|
+
# on_service_failure { |service| Rails.logger.error(service.errors.full_messages) }
|
|
305
|
+
def on_service_failure(method_name = nil, &)
|
|
306
|
+
register_callback(:on_service_failure, method_name, &)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Get callbacks defined in this class for a specific event.
|
|
310
|
+
#
|
|
311
|
+
# @param event [Symbol] the callback event name
|
|
312
|
+
# @return [Array<Symbol, Proc>] callbacks for this event
|
|
313
|
+
def callbacks_for(event)
|
|
314
|
+
@callbacks ||= {}
|
|
315
|
+
@callbacks[event] ||= []
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Get all callbacks for an event including inherited ones.
|
|
319
|
+
#
|
|
320
|
+
# @param event [Symbol] the callback event name
|
|
321
|
+
# @return [Array<Symbol, Proc>] all callbacks for this event
|
|
322
|
+
def all_callbacks_for(event)
|
|
323
|
+
if superclass.respond_to?(:all_callbacks_for)
|
|
324
|
+
inherited = superclass.all_callbacks_for(event)
|
|
325
|
+
else
|
|
326
|
+
inherited = []
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
inherited + callbacks_for(event)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private
|
|
333
|
+
|
|
334
|
+
# Registers a callback for a given event.
|
|
335
|
+
#
|
|
336
|
+
# @param event [Symbol] the callback event name
|
|
337
|
+
# @param method_name [Symbol, nil] name of the instance method to call
|
|
338
|
+
# @yield block to execute if no method name provided
|
|
339
|
+
# @return [void]
|
|
340
|
+
# @raise [ArgumentError] if neither method name nor block is provided
|
|
341
|
+
# @api private
|
|
342
|
+
def register_callback(event, method_name = nil, &block)
|
|
343
|
+
callback = method_name || block
|
|
344
|
+
raise ArgumentError, "#{event} requires a method name (symbol) or a block" unless callback
|
|
345
|
+
|
|
346
|
+
unless callback.is_a?(Symbol) || callback.is_a?(Proc)
|
|
347
|
+
raise ArgumentError, "#{event} callback must be a Symbol or Proc"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
callbacks_for(event) << callback
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
|
|
5
|
+
module Operandi
|
|
6
|
+
# Collection module for storing argument and output values.
|
|
7
|
+
module Collection
|
|
8
|
+
# Storage for service arguments or outputs with type validation.
|
|
9
|
+
#
|
|
10
|
+
# @example Accessing values
|
|
11
|
+
# service.arg[:name] # => "John"
|
|
12
|
+
# service.output[:user] # => #<User id: 1>
|
|
13
|
+
class Base
|
|
14
|
+
extend Forwardable
|
|
15
|
+
|
|
16
|
+
# @!method key?(key)
|
|
17
|
+
# Check if a key exists in the collection.
|
|
18
|
+
# @param key [Symbol] the key to check
|
|
19
|
+
# @return [Boolean] true if key exists
|
|
20
|
+
|
|
21
|
+
# @!method to_h
|
|
22
|
+
# Convert collection to a hash.
|
|
23
|
+
# @return [Hash] the stored values
|
|
24
|
+
def_delegators :@storage, :key?, :to_h
|
|
25
|
+
|
|
26
|
+
# Initialize a new collection.
|
|
27
|
+
#
|
|
28
|
+
# @param instance [Base] the service instance
|
|
29
|
+
# @param collection_type [String] "arguments" or "outputs"
|
|
30
|
+
# @param storage [Hash] initial values
|
|
31
|
+
# @raise [ArgTypeError] if storage is not a Hash
|
|
32
|
+
def initialize(instance, collection_type, storage = {})
|
|
33
|
+
validate_collection_type!(collection_type)
|
|
34
|
+
|
|
35
|
+
@instance = instance
|
|
36
|
+
@collection_type = collection_type
|
|
37
|
+
@storage = storage
|
|
38
|
+
|
|
39
|
+
return if storage.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
message = "#{instance.class} - #{collection_type} must be a Hash"
|
|
42
|
+
raise Operandi::ArgTypeError.new(message, service_class: instance.class)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Set a value in the collection.
|
|
46
|
+
#
|
|
47
|
+
# @param key [Symbol] the key to set
|
|
48
|
+
# @param value [Object] the value to store
|
|
49
|
+
# @return [Object] the stored value
|
|
50
|
+
def set(key, value)
|
|
51
|
+
@storage[key] = value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get a value from the collection.
|
|
55
|
+
#
|
|
56
|
+
# @param key [Symbol] the key to retrieve
|
|
57
|
+
# @return [Object, nil] the stored value or nil
|
|
58
|
+
def get(key)
|
|
59
|
+
@storage[key]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get a value using bracket notation.
|
|
63
|
+
#
|
|
64
|
+
# @param key [Symbol] the key to retrieve
|
|
65
|
+
# @return [Object, nil] the stored value or nil
|
|
66
|
+
def [](key)
|
|
67
|
+
get(key)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Set a value using bracket notation.
|
|
71
|
+
#
|
|
72
|
+
# @param key [Symbol] the key to set
|
|
73
|
+
# @param value [Object] the value to store
|
|
74
|
+
# @return [Object] the stored value
|
|
75
|
+
def []=(key, value)
|
|
76
|
+
set(key, value)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Load default values for fields that haven't been set.
|
|
80
|
+
#
|
|
81
|
+
# @return [void]
|
|
82
|
+
def load_defaults
|
|
83
|
+
settings_collection.each do |name, settings|
|
|
84
|
+
next if !settings.default_exists || key?(name)
|
|
85
|
+
|
|
86
|
+
if settings.default.is_a?(Proc)
|
|
87
|
+
set(name, @instance.instance_exec(&settings.default))
|
|
88
|
+
else
|
|
89
|
+
set(name, Utils.deep_dup(settings.default))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Validate all values against their type definitions.
|
|
95
|
+
#
|
|
96
|
+
# @return [void]
|
|
97
|
+
# @raise [ArgTypeError] if a value fails type validation
|
|
98
|
+
def validate!
|
|
99
|
+
settings_collection.each do |name, field|
|
|
100
|
+
next if field.optional && (!key?(name) || get(name).nil?)
|
|
101
|
+
|
|
102
|
+
# validate_type! returns the validated value
|
|
103
|
+
validated_value = field.validate_type!(get(name))
|
|
104
|
+
|
|
105
|
+
# Store the validated value back
|
|
106
|
+
set(name, validated_value) if validated_value != get(name)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Extend arguments hash with context values from this collection.
|
|
111
|
+
# Only applies to arguments collections.
|
|
112
|
+
#
|
|
113
|
+
# @param args [Hash] arguments hash to extend
|
|
114
|
+
# @return [Hash] the extended arguments hash
|
|
115
|
+
def extend_with_context(args)
|
|
116
|
+
return args unless @collection_type == CollectionTypes::ARGUMENTS
|
|
117
|
+
|
|
118
|
+
settings_collection.each do |name, field|
|
|
119
|
+
next if !field.context || args.key?(name) || !key?(name)
|
|
120
|
+
|
|
121
|
+
args[field.name] = get(name)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
args
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Access a value using method call syntax.
|
|
128
|
+
#
|
|
129
|
+
# @param method_name [Symbol] the method name (used as key)
|
|
130
|
+
# @param args [Array] method arguments (must be empty)
|
|
131
|
+
# @return [Object, nil] the stored value or nil
|
|
132
|
+
# @raise [NoMethodError] if the key doesn't exist
|
|
133
|
+
def method_missing(method_name, *args, &)
|
|
134
|
+
return super unless respond_to_missing?(method_name, false)
|
|
135
|
+
|
|
136
|
+
get(method_name)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Check if a method name corresponds to a stored key.
|
|
140
|
+
#
|
|
141
|
+
# @param method_name [Symbol] the method name to check
|
|
142
|
+
# @param include_private [Boolean] whether to include private methods
|
|
143
|
+
# @return [Boolean] true if the key exists
|
|
144
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
145
|
+
key?(method_name) || settings_collection.key?(method_name) || super
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def validate_collection_type!(type)
|
|
151
|
+
return if CollectionTypes::ALL.include?(type)
|
|
152
|
+
|
|
153
|
+
raise ArgumentError,
|
|
154
|
+
"collection_type must be one of #{CollectionTypes::ALL.join(', ')}, got: #{type.inspect}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def settings_collection
|
|
158
|
+
@instance.class.public_send(@collection_type)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Aliases for backwards compatibility
|
|
163
|
+
Arguments = Base
|
|
164
|
+
Outputs = Base
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
module Concerns
|
|
5
|
+
# Handles service execution logic including steps and validation
|
|
6
|
+
module Execution
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Execute the main service logic
|
|
10
|
+
def execute_service
|
|
11
|
+
self.class.validate_steps!
|
|
12
|
+
run_steps
|
|
13
|
+
run_steps_with_always
|
|
14
|
+
@output.validate! if success?
|
|
15
|
+
|
|
16
|
+
copy_warnings_to_parent_service
|
|
17
|
+
copy_errors_to_parent_service
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Run all service result callbacks based on success/failure
|
|
21
|
+
def run_service_result_callbacks
|
|
22
|
+
run_callbacks(:after_service_run, self)
|
|
23
|
+
|
|
24
|
+
if success?
|
|
25
|
+
run_callbacks(:on_service_success, self)
|
|
26
|
+
else
|
|
27
|
+
run_callbacks(:on_service_failure, self)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Run normal steps within transaction
|
|
32
|
+
def run_steps
|
|
33
|
+
within_transaction do
|
|
34
|
+
# Cache steps once for both normal and always execution
|
|
35
|
+
@cached_steps = self.class.steps
|
|
36
|
+
|
|
37
|
+
@cached_steps.each do |name, step|
|
|
38
|
+
@launched_steps << name if step.run(self)
|
|
39
|
+
|
|
40
|
+
break if @errors.break? || @warnings.break?
|
|
41
|
+
end
|
|
42
|
+
rescue Operandi::StopExecution
|
|
43
|
+
# Gracefully handle stop_immediately! inside transaction to prevent rollback
|
|
44
|
+
@stopped = true
|
|
45
|
+
end
|
|
46
|
+
rescue Operandi::FailExecution
|
|
47
|
+
# FailExecution bubbles out of transaction (causing rollback) but is caught here
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Run steps with parameter `always` if they weren't launched because of errors/warnings
|
|
52
|
+
def run_steps_with_always
|
|
53
|
+
# Use cached steps from run_steps, or get them if run_steps wasn't called
|
|
54
|
+
steps_to_check = @cached_steps || self.class.steps
|
|
55
|
+
|
|
56
|
+
steps_to_check.each do |name, step|
|
|
57
|
+
next if !step.always || @launched_steps.include?(name)
|
|
58
|
+
|
|
59
|
+
@launched_steps << name if step.run(self)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Load defaults for outputs and arguments, then validate arguments
|
|
64
|
+
def load_defaults_and_validate
|
|
65
|
+
@output.load_defaults
|
|
66
|
+
@arg.load_defaults
|
|
67
|
+
@arg.validate!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Execute block within transaction if configured
|
|
71
|
+
def within_transaction(&)
|
|
72
|
+
if @config[:use_transactions] && defined?(ActiveRecord::Base)
|
|
73
|
+
ActiveRecord::Base.transaction(requires_new: true, &)
|
|
74
|
+
else
|
|
75
|
+
yield
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
module Concerns
|
|
5
|
+
# Handles copying errors and warnings to parent services
|
|
6
|
+
module ParentService
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Copy warnings from this service to parent service
|
|
10
|
+
def copy_warnings_to_parent_service
|
|
11
|
+
return if !@parent_service || !@config[:load_warnings]
|
|
12
|
+
|
|
13
|
+
@parent_service.warnings.copy_from(
|
|
14
|
+
@warnings,
|
|
15
|
+
break: @config[:self_break_on_warning],
|
|
16
|
+
rollback: @config[:self_rollback_on_warning],
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Copy errors from this service to parent service
|
|
21
|
+
def copy_errors_to_parent_service
|
|
22
|
+
return if !@parent_service || !@config[:load_errors]
|
|
23
|
+
|
|
24
|
+
@parent_service.errors.copy_from(
|
|
25
|
+
@errors,
|
|
26
|
+
break: @config[:self_break_on_error],
|
|
27
|
+
rollback: @config[:self_rollback_on_error],
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Operandi
|
|
4
|
+
module Concerns
|
|
5
|
+
# Manages service state including errors and warnings initialization
|
|
6
|
+
module StateManagement
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Initialize errors collection with configuration
|
|
10
|
+
def initialize_errors
|
|
11
|
+
@errors = Messages.new(
|
|
12
|
+
{
|
|
13
|
+
break_on_add: @config[:break_on_error],
|
|
14
|
+
raise_on_add: @config[:raise_on_error],
|
|
15
|
+
rollback_on_add: @config[:use_transactions] && @config[:rollback_on_error],
|
|
16
|
+
},
|
|
17
|
+
service: self,
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Initialize warnings collection with configuration
|
|
22
|
+
def initialize_warnings
|
|
23
|
+
@warnings = Messages.new(
|
|
24
|
+
{
|
|
25
|
+
break_on_add: @config[:break_on_warning],
|
|
26
|
+
raise_on_add: @config[:raise_on_warning],
|
|
27
|
+
rollback_on_add: @config[:use_transactions] && @config[:rollback_on_warning],
|
|
28
|
+
},
|
|
29
|
+
service: self,
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|