senro_usecaser 0.2.0 → 0.4.1
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 +4 -4
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +23 -0
- data/README.md +850 -0
- data/examples/namespace_demo.rb +50 -15
- data/examples/order_system.rb +222 -34
- data/examples/sig/namespace_demo.rbs +35 -10
- data/examples/sig/order_system.rbs +196 -20
- data/lib/senro_usecaser/base.rb +387 -86
- data/lib/senro_usecaser/depends_on.rb +257 -0
- data/lib/senro_usecaser/hook.rb +28 -82
- data/lib/senro_usecaser/provider.rb +1 -1
- data/lib/senro_usecaser/retry_configuration.rb +131 -0
- data/lib/senro_usecaser/retry_context.rb +133 -0
- data/lib/senro_usecaser/version.rb +1 -1
- data/lib/senro_usecaser.rb +3 -0
- data/sig/generated/senro_usecaser/base.rbs +179 -37
- data/sig/generated/senro_usecaser/depends_on.rbs +197 -0
- data/sig/generated/senro_usecaser/hook.rbs +23 -35
- data/sig/generated/senro_usecaser/provider.rbs +1 -1
- data/sig/generated/senro_usecaser/retry_configuration.rbs +90 -0
- data/sig/generated/senro_usecaser/retry_context.rbs +101 -0
- data/sig/overrides.rbs +1 -2
- metadata +7 -1
data/lib/senro_usecaser/base.rb
CHANGED
|
@@ -121,44 +121,13 @@ module SenroUsecaser
|
|
|
121
121
|
# organize StepA, StepB
|
|
122
122
|
# end
|
|
123
123
|
class Base
|
|
124
|
-
|
|
125
|
-
# Declares a dependency to be injected from the container
|
|
126
|
-
#
|
|
127
|
-
#: (Symbol, ?Class) -> void
|
|
128
|
-
def depends_on(name, type = nil)
|
|
129
|
-
dependencies << name unless dependencies.include?(name)
|
|
130
|
-
dependency_types[name] = type if type
|
|
131
|
-
|
|
132
|
-
define_method(name) do
|
|
133
|
-
@_dependencies[name]
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Returns the list of declared dependencies
|
|
138
|
-
#
|
|
139
|
-
#: () -> Array[Symbol]
|
|
140
|
-
def dependencies
|
|
141
|
-
@dependencies ||= []
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Returns the dependency type mapping
|
|
145
|
-
#
|
|
146
|
-
#: () -> Hash[Symbol, Class]
|
|
147
|
-
def dependency_types
|
|
148
|
-
@dependency_types ||= {}
|
|
149
|
-
end
|
|
124
|
+
extend DependsOn
|
|
150
125
|
|
|
151
|
-
|
|
152
|
-
#
|
|
153
|
-
#: ((Symbol | String)) -> void
|
|
154
|
-
def namespace(name)
|
|
155
|
-
@use_case_namespace = name
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Returns the declared namespace
|
|
126
|
+
class << self
|
|
127
|
+
# Alias for backward compatibility
|
|
159
128
|
#
|
|
160
129
|
#: () -> (Symbol | String)?
|
|
161
|
-
|
|
130
|
+
alias use_case_namespace declared_namespace
|
|
162
131
|
|
|
163
132
|
# Declares a sequence of UseCases to execute as a pipeline
|
|
164
133
|
#
|
|
@@ -276,17 +245,136 @@ module SenroUsecaser
|
|
|
276
245
|
@around_hooks ||= []
|
|
277
246
|
end
|
|
278
247
|
|
|
279
|
-
#
|
|
248
|
+
# Adds an on_failure hook
|
|
280
249
|
#
|
|
281
|
-
#: (
|
|
282
|
-
def
|
|
283
|
-
|
|
250
|
+
#: () { (untyped, Result[untyped], ?RetryContext?) -> void } -> void
|
|
251
|
+
def on_failure(&block)
|
|
252
|
+
on_failure_hooks << block
|
|
284
253
|
end
|
|
285
254
|
|
|
286
|
-
# Returns the
|
|
255
|
+
# Returns the list of on_failure hooks
|
|
287
256
|
#
|
|
288
|
-
#: () ->
|
|
289
|
-
|
|
257
|
+
#: () -> Array[Proc]
|
|
258
|
+
def on_failure_hooks
|
|
259
|
+
@on_failure_hooks ||= []
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Configures automatic retry for specific error types
|
|
263
|
+
#
|
|
264
|
+
# @example Retry on network errors
|
|
265
|
+
# retry_on :network_error, attempts: 3, wait: 1
|
|
266
|
+
#
|
|
267
|
+
# @example Retry on exception class
|
|
268
|
+
# retry_on Net::OpenTimeout, attempts: 5, wait: 2, backoff: :exponential
|
|
269
|
+
#
|
|
270
|
+
# @example Multiple error types with jitter
|
|
271
|
+
# retry_on :rate_limited, :timeout, attempts: 3, wait: 1, jitter: 0.1
|
|
272
|
+
#
|
|
273
|
+
# rubocop:disable Metrics/ParameterLists
|
|
274
|
+
#: (*(Symbol | Class), ?attempts: Integer, ?wait: (Float | Integer),
|
|
275
|
+
#: ?backoff: Symbol, ?max_wait: (Float | Integer)?, ?jitter: (Float | Integer)) -> void
|
|
276
|
+
def retry_on(*error_matchers, attempts: 3, wait: 0, backoff: :fixed, max_wait: nil, jitter: 0)
|
|
277
|
+
retry_configurations << RetryConfiguration.new(
|
|
278
|
+
matchers: error_matchers.flatten,
|
|
279
|
+
attempts: attempts,
|
|
280
|
+
wait: wait,
|
|
281
|
+
backoff: backoff,
|
|
282
|
+
max_wait: max_wait,
|
|
283
|
+
jitter: jitter
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
# rubocop:enable Metrics/ParameterLists
|
|
287
|
+
|
|
288
|
+
# Returns the list of retry configurations
|
|
289
|
+
#
|
|
290
|
+
#: () -> Array[RetryConfiguration]
|
|
291
|
+
def retry_configurations
|
|
292
|
+
@retry_configurations ||= []
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Configures errors that should immediately discard (no retry)
|
|
296
|
+
#
|
|
297
|
+
# @example Discard on validation errors
|
|
298
|
+
# discard_on :validation_error, :not_found
|
|
299
|
+
#
|
|
300
|
+
# @example Discard on exception class
|
|
301
|
+
# discard_on ArgumentError
|
|
302
|
+
#
|
|
303
|
+
#: (*(Symbol | Class)) -> void
|
|
304
|
+
def discard_on(*error_matchers)
|
|
305
|
+
discard_matchers.concat(error_matchers.flatten)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Returns the list of discard matchers
|
|
309
|
+
#
|
|
310
|
+
#: () -> Array[(Symbol | Class)]
|
|
311
|
+
def discard_matchers
|
|
312
|
+
@discard_matchers ||= []
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Adds a before_retry hook
|
|
316
|
+
#
|
|
317
|
+
#: () { (untyped, Result[untyped], RetryContext) -> void } -> void
|
|
318
|
+
def before_retry(&block)
|
|
319
|
+
before_retry_hooks << block
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Returns the list of before_retry hooks
|
|
323
|
+
#
|
|
324
|
+
#: () -> Array[Proc]
|
|
325
|
+
def before_retry_hooks
|
|
326
|
+
@before_retry_hooks ||= []
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Adds an after_retries_exhausted hook
|
|
330
|
+
#
|
|
331
|
+
#: () { (untyped, Result[untyped], RetryContext) -> void } -> void
|
|
332
|
+
def after_retries_exhausted(&block)
|
|
333
|
+
after_retries_exhausted_hooks << block
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Returns the list of after_retries_exhausted hooks
|
|
337
|
+
#
|
|
338
|
+
#: () -> Array[Proc]
|
|
339
|
+
def after_retries_exhausted_hooks
|
|
340
|
+
@after_retries_exhausted_hooks ||= []
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Declares the expected input type(s) for this UseCase
|
|
344
|
+
# Accepts a Class or one or more Modules that input must include
|
|
345
|
+
#
|
|
346
|
+
# @example Single class
|
|
347
|
+
# input UserInput
|
|
348
|
+
#
|
|
349
|
+
# @example Single module (interface)
|
|
350
|
+
# input HasUserId
|
|
351
|
+
#
|
|
352
|
+
# @example Multiple modules (interfaces)
|
|
353
|
+
# input HasUserId, HasEmail
|
|
354
|
+
#
|
|
355
|
+
#: (*Module) -> void
|
|
356
|
+
def input(*types)
|
|
357
|
+
@input_types = types
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Returns the input types as an array
|
|
361
|
+
#
|
|
362
|
+
#: () -> Array[Module]
|
|
363
|
+
def input_types
|
|
364
|
+
@input_types || []
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Returns the input class (for backwards compatibility)
|
|
368
|
+
# If a Class is specified, returns it. Otherwise returns the first type.
|
|
369
|
+
#
|
|
370
|
+
#: () -> Module?
|
|
371
|
+
def input_class
|
|
372
|
+
types = input_types
|
|
373
|
+
return nil if types.empty?
|
|
374
|
+
|
|
375
|
+
# Class があればそれを返す(単一 Class 指定の後方互換)
|
|
376
|
+
types.find { |t| t.is_a?(Class) } || types.first
|
|
377
|
+
end
|
|
290
378
|
|
|
291
379
|
# Declares the expected output type for this UseCase
|
|
292
380
|
#
|
|
@@ -335,13 +423,13 @@ module SenroUsecaser
|
|
|
335
423
|
private
|
|
336
424
|
|
|
337
425
|
def copy_configuration_to(subclass)
|
|
338
|
-
subclass
|
|
339
|
-
subclass.instance_variable_set(:@dependency_types, dependency_types.dup)
|
|
340
|
-
subclass.instance_variable_set(:@use_case_namespace, @use_case_namespace)
|
|
426
|
+
copy_depends_on_to(subclass)
|
|
341
427
|
subclass.instance_variable_set(:@organized_steps, @organized_steps&.dup)
|
|
342
428
|
subclass.instance_variable_set(:@on_failure_strategy, @on_failure_strategy)
|
|
343
|
-
subclass.instance_variable_set(:@
|
|
429
|
+
subclass.instance_variable_set(:@input_types, @input_types&.dup)
|
|
344
430
|
subclass.instance_variable_set(:@output_schema, @output_schema)
|
|
431
|
+
subclass.instance_variable_set(:@retry_configurations, retry_configurations.dup)
|
|
432
|
+
subclass.instance_variable_set(:@discard_matchers, discard_matchers.dup)
|
|
345
433
|
end
|
|
346
434
|
|
|
347
435
|
def copy_hooks_to(subclass)
|
|
@@ -349,6 +437,9 @@ module SenroUsecaser
|
|
|
349
437
|
subclass.instance_variable_set(:@before_hooks, before_hooks.dup)
|
|
350
438
|
subclass.instance_variable_set(:@after_hooks, after_hooks.dup)
|
|
351
439
|
subclass.instance_variable_set(:@around_hooks, around_hooks.dup)
|
|
440
|
+
subclass.instance_variable_set(:@on_failure_hooks, on_failure_hooks.dup)
|
|
441
|
+
subclass.instance_variable_set(:@before_retry_hooks, before_retry_hooks.dup)
|
|
442
|
+
subclass.instance_variable_set(:@after_retries_exhausted_hooks, after_retries_exhausted_hooks.dup)
|
|
352
443
|
end
|
|
353
444
|
end
|
|
354
445
|
|
|
@@ -372,9 +463,8 @@ module SenroUsecaser
|
|
|
372
463
|
raise ArgumentError, "#{self.class.name} must define `input` class"
|
|
373
464
|
end
|
|
374
465
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
end
|
|
466
|
+
validate_input!(input)
|
|
467
|
+
execute_with_retry(input)
|
|
378
468
|
end
|
|
379
469
|
|
|
380
470
|
# Executes the UseCase logic
|
|
@@ -386,6 +476,9 @@ module SenroUsecaser
|
|
|
386
476
|
raise NotImplementedError, "#{self.class.name}#call must be implemented"
|
|
387
477
|
end
|
|
388
478
|
|
|
479
|
+
# Represents a record of a step execution in a pipeline
|
|
480
|
+
StepExecutionRecord = Struct.new(:step, :input, :result, keyword_init: true)
|
|
481
|
+
|
|
389
482
|
private
|
|
390
483
|
|
|
391
484
|
# Creates a success Result with the given value
|
|
@@ -416,6 +509,48 @@ module SenroUsecaser
|
|
|
416
509
|
Result.capture(*exception_classes, code: code, &)
|
|
417
510
|
end
|
|
418
511
|
|
|
512
|
+
# Validates that input satisfies all declared input types
|
|
513
|
+
# For Modules: checks if input's class includes the module
|
|
514
|
+
# For Classes: checks if input is an instance of the class
|
|
515
|
+
#
|
|
516
|
+
#: (untyped) -> void
|
|
517
|
+
def validate_input!(input)
|
|
518
|
+
types = self.class.input_types
|
|
519
|
+
return if types.empty?
|
|
520
|
+
|
|
521
|
+
types.each do |expected_type|
|
|
522
|
+
if expected_type.is_a?(Module) && !expected_type.is_a?(Class)
|
|
523
|
+
# Module の場合: include しているかを検査
|
|
524
|
+
unless input.class.include?(expected_type)
|
|
525
|
+
raise ArgumentError,
|
|
526
|
+
"Input #{input.class} must include #{expected_type}"
|
|
527
|
+
end
|
|
528
|
+
elsif !input.is_a?(expected_type)
|
|
529
|
+
# Class の場合: インスタンスかを検査
|
|
530
|
+
raise ArgumentError,
|
|
531
|
+
"Input must be an instance of #{expected_type}, got #{input.class}"
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Validates that the result's value satisfies the declared output type
|
|
537
|
+
# Only validates if result is success and output_schema is a Class
|
|
538
|
+
#
|
|
539
|
+
#: (Result[untyped]) -> void
|
|
540
|
+
def validate_output!(result)
|
|
541
|
+
return unless result.success?
|
|
542
|
+
|
|
543
|
+
expected_type = self.class.output_schema
|
|
544
|
+
return if expected_type.nil?
|
|
545
|
+
return unless expected_type.is_a?(Class)
|
|
546
|
+
|
|
547
|
+
value = result.value
|
|
548
|
+
return if value.is_a?(expected_type)
|
|
549
|
+
|
|
550
|
+
raise TypeError,
|
|
551
|
+
"Output must be an instance of #{expected_type}, got #{value.class}"
|
|
552
|
+
end
|
|
553
|
+
|
|
419
554
|
# Executes the core logic with before/after/around hooks
|
|
420
555
|
#
|
|
421
556
|
#: (untyped) { () -> Result[untyped] } -> Result[untyped]
|
|
@@ -423,10 +558,95 @@ module SenroUsecaser
|
|
|
423
558
|
execution = build_around_chain(input, core_block)
|
|
424
559
|
run_before_hooks(input)
|
|
425
560
|
result = execution.call
|
|
561
|
+
validate_output!(result)
|
|
426
562
|
run_after_hooks(input, result)
|
|
427
563
|
result
|
|
428
564
|
end
|
|
429
565
|
|
|
566
|
+
# Executes the UseCase with retry support
|
|
567
|
+
#
|
|
568
|
+
#: (untyped) -> Result[untyped]
|
|
569
|
+
def execute_with_retry(input)
|
|
570
|
+
context = build_retry_context
|
|
571
|
+
current_input = input
|
|
572
|
+
|
|
573
|
+
loop do
|
|
574
|
+
result = execute_with_hooks(current_input) { call(current_input) }
|
|
575
|
+
|
|
576
|
+
return result if result.success?
|
|
577
|
+
return result if should_discard?(result)
|
|
578
|
+
|
|
579
|
+
retry_config = find_matching_retry_config(result)
|
|
580
|
+
run_on_failure_hooks(current_input, result, context)
|
|
581
|
+
|
|
582
|
+
should_retry = context.should_retry? || (retry_config && !context.exhausted?)
|
|
583
|
+
|
|
584
|
+
unless should_retry
|
|
585
|
+
run_after_retries_exhausted_hooks(current_input, result, context) if context.retried?
|
|
586
|
+
return result
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
wait_time = context.retry_wait || retry_config&.calculate_wait(context.attempt) || 0
|
|
590
|
+
run_before_retry_hooks(current_input, result, context)
|
|
591
|
+
|
|
592
|
+
sleep(wait_time) if wait_time.positive?
|
|
593
|
+
|
|
594
|
+
current_input = context.retry_input || current_input
|
|
595
|
+
context.increment!(last_error: result.errors.first)
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Builds a retry context with max attempts from configurations
|
|
600
|
+
#
|
|
601
|
+
#: () -> RetryContext
|
|
602
|
+
def build_retry_context
|
|
603
|
+
max_attempts = self.class.retry_configurations.map(&:attempts).max
|
|
604
|
+
RetryContext.new(max_attempts: max_attempts)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Finds a retry configuration that matches the result
|
|
608
|
+
#
|
|
609
|
+
#: (Result[untyped]) -> RetryConfiguration?
|
|
610
|
+
def find_matching_retry_config(result)
|
|
611
|
+
self.class.retry_configurations.find { |c| c.matches?(result) }
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Checks if the result should be discarded (no retry)
|
|
615
|
+
#
|
|
616
|
+
#: (Result[untyped]) -> bool
|
|
617
|
+
def should_discard?(result)
|
|
618
|
+
return false unless result.failure?
|
|
619
|
+
|
|
620
|
+
result.errors.any? do |error|
|
|
621
|
+
self.class.discard_matchers.any? do |matcher|
|
|
622
|
+
case matcher
|
|
623
|
+
when Symbol
|
|
624
|
+
error.code == matcher
|
|
625
|
+
when Class
|
|
626
|
+
error.cause&.is_a?(matcher)
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Runs before_retry hooks
|
|
633
|
+
#
|
|
634
|
+
#: (untyped, Result[untyped], RetryContext) -> void
|
|
635
|
+
def run_before_retry_hooks(input, result, context)
|
|
636
|
+
self.class.before_retry_hooks.each do |hook|
|
|
637
|
+
instance_exec(input, result, context, &hook) # steep:ignore BlockTypeMismatch
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Runs after_retries_exhausted hooks
|
|
642
|
+
#
|
|
643
|
+
#: (untyped, Result[untyped], RetryContext) -> void
|
|
644
|
+
def run_after_retries_exhausted_hooks(input, result, context)
|
|
645
|
+
self.class.after_retries_exhausted_hooks.each do |hook|
|
|
646
|
+
instance_exec(input, result, context, &hook) # steep:ignore BlockTypeMismatch
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
430
650
|
# Wraps a non-Result value in Result.success
|
|
431
651
|
#
|
|
432
652
|
#: (untyped) -> Result[untyped]
|
|
@@ -507,6 +727,44 @@ module SenroUsecaser
|
|
|
507
727
|
self.class.after_hooks.each { |hook| instance_exec(input, result, &hook) } # steep:ignore BlockTypeMismatch
|
|
508
728
|
end
|
|
509
729
|
|
|
730
|
+
# Runs all on_failure hooks when result is a failure
|
|
731
|
+
#
|
|
732
|
+
#: (untyped, Result[untyped], ?RetryContext?) -> void
|
|
733
|
+
def run_on_failure_hooks(input, result, context = nil)
|
|
734
|
+
return unless result.failure?
|
|
735
|
+
|
|
736
|
+
hook_instances.each do |hook_instance|
|
|
737
|
+
call_on_failure_hook(hook_instance, :on_failure, input, result, context)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
self.class.extensions.each do |ext|
|
|
741
|
+
next if hook_class?(ext)
|
|
742
|
+
next unless ext.respond_to?(:on_failure)
|
|
743
|
+
|
|
744
|
+
call_on_failure_hook(ext, :on_failure, input, result, context)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
self.class.on_failure_hooks.each do |hook|
|
|
748
|
+
if context && (hook.arity == 3 || hook.arity.negative?)
|
|
749
|
+
instance_exec(input, result, context, &hook) # steep:ignore BlockTypeMismatch
|
|
750
|
+
else
|
|
751
|
+
instance_exec(input, result, &hook) # steep:ignore BlockTypeMismatch
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Calls an on_failure hook with appropriate arguments
|
|
757
|
+
#
|
|
758
|
+
#: (untyped, Symbol, untyped, Result[untyped], RetryContext?) -> void
|
|
759
|
+
def call_on_failure_hook(target, method_name, input, result, context)
|
|
760
|
+
method = target.method(method_name)
|
|
761
|
+
if context && (method.arity == 3 || method.arity.negative?)
|
|
762
|
+
target.send(method_name, input, result, context)
|
|
763
|
+
else
|
|
764
|
+
target.send(method_name, input, result)
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
510
768
|
# Returns instantiated hook objects
|
|
511
769
|
#
|
|
512
770
|
#: () -> Array[Hook]
|
|
@@ -540,43 +798,18 @@ module SenroUsecaser
|
|
|
540
798
|
end
|
|
541
799
|
|
|
542
800
|
# Resolves a single dependency from the container
|
|
801
|
+
# Overrides DependsOn::InstanceMethods to accept container as parameter
|
|
543
802
|
#
|
|
544
803
|
#: (Container, Symbol) -> untyped
|
|
545
804
|
def resolve_from_container(container, name)
|
|
546
|
-
|
|
547
|
-
if
|
|
548
|
-
container.resolve_in(
|
|
805
|
+
ns = effective_namespace
|
|
806
|
+
if ns
|
|
807
|
+
container.resolve_in(ns, name)
|
|
549
808
|
else
|
|
550
809
|
container.resolve(name)
|
|
551
810
|
end
|
|
552
811
|
end
|
|
553
812
|
|
|
554
|
-
# Returns the effective namespace for dependency resolution
|
|
555
|
-
#
|
|
556
|
-
#: () -> (Symbol | String)?
|
|
557
|
-
def effective_namespace
|
|
558
|
-
return self.class.use_case_namespace if self.class.use_case_namespace
|
|
559
|
-
return nil unless SenroUsecaser.configuration.infer_namespace_from_module
|
|
560
|
-
|
|
561
|
-
infer_namespace_from_class
|
|
562
|
-
end
|
|
563
|
-
|
|
564
|
-
# Infers namespace from the class's module structure
|
|
565
|
-
#
|
|
566
|
-
#: () -> String?
|
|
567
|
-
def infer_namespace_from_class
|
|
568
|
-
class_name = self.class.name
|
|
569
|
-
return nil unless class_name
|
|
570
|
-
|
|
571
|
-
parts = class_name.split("::")
|
|
572
|
-
return nil if parts.length <= 1
|
|
573
|
-
|
|
574
|
-
module_parts = parts[0...-1] || [] #: Array[String]
|
|
575
|
-
return nil if module_parts.empty?
|
|
576
|
-
|
|
577
|
-
module_parts.map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase }.join("::")
|
|
578
|
-
end
|
|
579
|
-
|
|
580
813
|
# Executes the organized UseCase pipeline
|
|
581
814
|
#
|
|
582
815
|
#: (untyped) -> Result[untyped]
|
|
@@ -599,12 +832,18 @@ module SenroUsecaser
|
|
|
599
832
|
def execute_pipeline_stop(input)
|
|
600
833
|
current_input = input
|
|
601
834
|
result = nil #: Result[untyped]?
|
|
835
|
+
executed_steps = [] #: Array[StepExecutionRecord]
|
|
602
836
|
|
|
603
837
|
self.class.organized_steps&.each do |step|
|
|
604
838
|
next unless step.should_execute?(current_input, self)
|
|
605
839
|
|
|
606
840
|
step_result = execute_step(step, current_input)
|
|
607
|
-
|
|
841
|
+
executed_steps << StepExecutionRecord.new(step: step, input: current_input, result: step_result)
|
|
842
|
+
|
|
843
|
+
if step_result.failure? && step_should_stop?(step)
|
|
844
|
+
execute_pipeline_rollback(executed_steps)
|
|
845
|
+
return step_result
|
|
846
|
+
end
|
|
608
847
|
|
|
609
848
|
current_input = step_result.value if step_result.success?
|
|
610
849
|
result = step_result
|
|
@@ -619,12 +858,18 @@ module SenroUsecaser
|
|
|
619
858
|
def execute_pipeline_continue(input)
|
|
620
859
|
current_input = input
|
|
621
860
|
result = nil #: Result[untyped]?
|
|
861
|
+
executed_steps = [] #: Array[StepExecutionRecord]
|
|
622
862
|
|
|
623
863
|
self.class.organized_steps&.each do |step|
|
|
624
864
|
next unless step.should_execute?(current_input, self)
|
|
625
865
|
|
|
626
866
|
step_result = execute_step(step, current_input)
|
|
627
|
-
|
|
867
|
+
executed_steps << StepExecutionRecord.new(step: step, input: current_input, result: step_result)
|
|
868
|
+
|
|
869
|
+
if step_result.failure? && step.on_failure == :stop
|
|
870
|
+
execute_pipeline_rollback(executed_steps)
|
|
871
|
+
return step_result
|
|
872
|
+
end
|
|
628
873
|
|
|
629
874
|
current_input = step_result.value if step_result.success?
|
|
630
875
|
result = step_result
|
|
@@ -638,16 +883,20 @@ module SenroUsecaser
|
|
|
638
883
|
#: (untyped) -> Result[untyped]
|
|
639
884
|
def execute_pipeline_collect(input)
|
|
640
885
|
errors = [] #: Array[Error]
|
|
886
|
+
executed_steps = [] #: Array[StepExecutionRecord]
|
|
641
887
|
state = { input: input, errors: errors, last_success: nil }
|
|
642
888
|
|
|
643
889
|
self.class.organized_steps&.each do |step|
|
|
644
890
|
next unless step.should_execute?(state[:input], self)
|
|
645
891
|
|
|
646
892
|
result = execute_step(step, state[:input])
|
|
893
|
+
executed_steps << StepExecutionRecord.new(step: step, input: state[:input], result: result)
|
|
647
894
|
break if should_stop_collect_pipeline?(result, step, state)
|
|
648
895
|
end
|
|
649
896
|
|
|
650
|
-
build_collect_result(state)
|
|
897
|
+
final_result = build_collect_result(state)
|
|
898
|
+
execute_pipeline_rollback(executed_steps) if final_result.failure?
|
|
899
|
+
final_result
|
|
651
900
|
end
|
|
652
901
|
|
|
653
902
|
# Updates collect state and checks if pipeline should stop
|
|
@@ -692,16 +941,68 @@ module SenroUsecaser
|
|
|
692
941
|
end
|
|
693
942
|
|
|
694
943
|
# Calls a single UseCase in the pipeline
|
|
695
|
-
# Requires
|
|
944
|
+
# Requires input type(s) to be defined for pipeline steps
|
|
945
|
+
# Note: on_failure hooks are not called here - they're called in pipeline rollback
|
|
696
946
|
#
|
|
697
947
|
#: (singleton(Base), untyped) -> Result[untyped]
|
|
698
948
|
def call_use_case(use_case_class, input)
|
|
699
|
-
|
|
700
|
-
raise ArgumentError, "#{use_case_class.name} must define `input`
|
|
949
|
+
if use_case_class.input_types.empty?
|
|
950
|
+
raise ArgumentError, "#{use_case_class.name} must define `input` type(s) to be used in a pipeline"
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
instance = use_case_class.new(container: @_container)
|
|
954
|
+
instance.send(:perform_as_pipeline_step, input, capture_exceptions: @_capture_exceptions || false)
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
# Performs the UseCase as a pipeline step (without on_failure hooks)
|
|
958
|
+
# on_failure hooks are handled by the pipeline's rollback mechanism instead
|
|
959
|
+
#
|
|
960
|
+
#: (untyped, ?capture_exceptions: bool) -> Result[untyped]
|
|
961
|
+
def perform_as_pipeline_step(input, capture_exceptions: false)
|
|
962
|
+
@_capture_exceptions = capture_exceptions
|
|
963
|
+
|
|
964
|
+
unless self.class.input_class || self.class.organized_steps
|
|
965
|
+
raise ArgumentError, "#{self.class.name} must define `input` class"
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
validate_input!(input)
|
|
969
|
+
execute_with_hooks(input) { call(input) }
|
|
970
|
+
rescue StandardError => e
|
|
971
|
+
raise unless capture_exceptions
|
|
972
|
+
|
|
973
|
+
Result.from_exception(e)
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# Executes rollback by calling on_failure hooks on executed steps in reverse order
|
|
977
|
+
# Unlike run_on_failure_hooks, this method calls hooks regardless of result status
|
|
978
|
+
# because we want to rollback even successfully completed steps when pipeline fails
|
|
979
|
+
#
|
|
980
|
+
#: (Array[StepExecutionRecord]) -> void
|
|
981
|
+
def execute_pipeline_rollback(executed_steps)
|
|
982
|
+
executed_steps.reverse_each do |record|
|
|
983
|
+
step_instance = record.step.use_case_class.new(container: @_container)
|
|
984
|
+
step_instance.send(:run_rollback_hooks, record.input, record.result)
|
|
985
|
+
end
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# Runs on_failure hooks for rollback purposes (regardless of result status)
|
|
989
|
+
#
|
|
990
|
+
#: (untyped, Result[untyped]) -> void
|
|
991
|
+
def run_rollback_hooks(input, result)
|
|
992
|
+
hook_instances.each do |hook_instance|
|
|
993
|
+
call_on_failure_hook(hook_instance, :on_failure, input, result, nil)
|
|
701
994
|
end
|
|
702
995
|
|
|
703
|
-
|
|
704
|
-
|
|
996
|
+
self.class.extensions.each do |ext|
|
|
997
|
+
next if hook_class?(ext)
|
|
998
|
+
next unless ext.respond_to?(:on_failure)
|
|
999
|
+
|
|
1000
|
+
call_on_failure_hook(ext, :on_failure, input, result, nil)
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
self.class.on_failure_hooks.each do |hook|
|
|
1004
|
+
instance_exec(input, result, &hook) # steep:ignore BlockTypeMismatch
|
|
1005
|
+
end
|
|
705
1006
|
end
|
|
706
1007
|
end
|
|
707
1008
|
end
|