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.
@@ -121,44 +121,13 @@ module SenroUsecaser
121
121
  # organize StepA, StepB
122
122
  # end
123
123
  class Base
124
- class << self
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
- # Sets the namespace for dependency resolution
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
- attr_reader :use_case_namespace
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
- # Declares the expected input type for this UseCase
248
+ # Adds an on_failure hook
280
249
  #
281
- #: (Class) -> void
282
- def input(type)
283
- @input_class = type
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 input class
255
+ # Returns the list of on_failure hooks
287
256
  #
288
- #: () -> Class?
289
- attr_reader :input_class
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.instance_variable_set(:@dependencies, dependencies.dup)
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(:@input_class, @input_class)
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
- execute_with_hooks(input) do
376
- call(input)
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
- namespace = effective_namespace
547
- if namespace
548
- container.resolve_in(namespace, name)
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
- return step_result if step_result.failure? && step_should_stop?(step)
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
- return step_result if step_result.failure? && step.on_failure == :stop
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 input_class to be defined for pipeline steps
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
- unless use_case_class.input_class
700
- raise ArgumentError, "#{use_case_class.name} must define `input` class to be used in a pipeline"
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
- call_method = @_capture_exceptions || false ? :call! : :call #: Symbol
704
- use_case_class.public_send(call_method, input, container: @_container)
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