senro_usecaser 0.3.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.
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module SenroUsecaser
6
+ # Module that provides dependency injection support
7
+ #
8
+ # This module can be extended into any class to enable the full DI functionality
9
+ # similar to UseCase and Hook classes, including:
10
+ # - `depends_on` for declaring dependencies
11
+ # - `namespace` for scoped dependency resolution
12
+ # - Automatic `infer_namespace_from_module` support
13
+ # - Default `initialize` that sets up dependency injection (uses SenroUsecaser.container if not provided)
14
+ #
15
+ # @example Basic usage (no initialize needed)
16
+ # class MyService
17
+ # extend SenroUsecaser::DependsOn
18
+ #
19
+ # depends_on :logger, Logger
20
+ # depends_on :repository
21
+ #
22
+ # # No initialize needed! Default is provided automatically.
23
+ #
24
+ # def perform
25
+ # logger.info("Performing...")
26
+ # repository.find(1)
27
+ # end
28
+ # end
29
+ #
30
+ # service = MyService.new # Uses SenroUsecaser.container
31
+ # service.logger # => Logger instance
32
+ #
33
+ # @example Custom initialize with super
34
+ # class MyService
35
+ # extend SenroUsecaser::DependsOn
36
+ #
37
+ # depends_on :logger
38
+ # attr_reader :extra
39
+ #
40
+ # def initialize(extra:, container: nil)
41
+ # super(container: container) # Handles dependency resolution
42
+ # @extra = extra
43
+ # end
44
+ # end
45
+ #
46
+ # @example With explicit namespace
47
+ # class Admin::UserService
48
+ # extend SenroUsecaser::DependsOn
49
+ #
50
+ # namespace :admin
51
+ # depends_on :user_repository, UserRepository
52
+ # end
53
+ #
54
+ # @example With infer_namespace_from_module (when configured)
55
+ # # When SenroUsecaser.configuration.infer_namespace_from_module = true
56
+ # class Admin::Orders::ProcessService
57
+ # extend SenroUsecaser::DependsOn
58
+ #
59
+ # depends_on :order_repository # resolved from "admin::orders" namespace
60
+ # end
61
+ module DependsOn
62
+ # Hook called when module is extended into a class
63
+ # Automatically includes InstanceMethods
64
+ #
65
+ def self.extended(base)
66
+ base.include(InstanceMethods)
67
+ end
68
+
69
+ # Declares a dependency to be injected from the container
70
+ #
71
+ # @param name [Symbol] The name of the dependency
72
+ # @param type [Class, nil] Optional expected type for the dependency
73
+ #
74
+ # @example Basic dependency
75
+ # depends_on :logger
76
+ #
77
+ # @example Typed dependency
78
+ # depends_on :repository, UserRepository
79
+ #
80
+ #: (Symbol, ?Class) -> void
81
+ def depends_on(name, type = nil)
82
+ dependencies << name unless dependencies.include?(name)
83
+ dependency_types[name] = type if type
84
+
85
+ define_method(name) do # steep:ignore NoMethod
86
+ @_dependencies[name]
87
+ end
88
+ end
89
+
90
+ # Returns the list of declared dependencies
91
+ #
92
+ #: () -> Array[Symbol]
93
+ def dependencies
94
+ @dependencies ||= []
95
+ end
96
+
97
+ # Returns the dependency type mapping
98
+ #
99
+ #: () -> Hash[Symbol, Class]
100
+ def dependency_types
101
+ @dependency_types ||= {}
102
+ end
103
+
104
+ # Sets or returns the namespace for dependency resolution
105
+ #
106
+ # @param name [Symbol, String, nil] The namespace to set
107
+ # @return [Symbol, String, nil] The current namespace when no argument given
108
+ #
109
+ # @example Setting namespace
110
+ # namespace :admin
111
+ #
112
+ # @example Getting namespace
113
+ # current_ns = namespace
114
+ #
115
+ #: (?(Symbol | String)) -> (Symbol | String)?
116
+ def namespace(name = nil)
117
+ if name
118
+ @declared_namespace = name
119
+ else
120
+ @declared_namespace
121
+ end
122
+ end
123
+
124
+ # Returns the declared namespace
125
+ #
126
+ #: () -> (Symbol | String)?
127
+ def declared_namespace
128
+ @declared_namespace
129
+ end
130
+
131
+ # Copies dependency configuration to a subclass
132
+ #
133
+ # @param subclass [Class] The subclass to copy dependencies to
134
+ #
135
+ # @example In inherited hook
136
+ # def self.inherited(subclass)
137
+ # super
138
+ # copy_depends_on_to(subclass)
139
+ # end
140
+ #
141
+ #: (Class) -> void
142
+ def copy_depends_on_to(subclass)
143
+ subclass.instance_variable_set(:@dependencies, dependencies.dup)
144
+ subclass.instance_variable_set(:@dependency_types, dependency_types.dup)
145
+ subclass.instance_variable_set(:@declared_namespace, @declared_namespace)
146
+ end
147
+
148
+ # Instance methods for dependency resolution
149
+ #
150
+ # These methods are automatically included when DependsOn is extended.
151
+ # They require @_container and @_dependencies instance variables to be set.
152
+ module InstanceMethods
153
+ # Default initialize for classes using DependsOn
154
+ #
155
+ # This provides a default initialize that sets up dependency injection.
156
+ # Classes can override this and call super to extend the behavior.
157
+ #
158
+ # @param container [Container, nil] The DI container to resolve dependencies from.
159
+ # If nil, uses SenroUsecaser.container.
160
+ #
161
+ # @example Default usage (no arguments needed)
162
+ # class MyService
163
+ # extend SenroUsecaser::DependsOn
164
+ # depends_on :logger
165
+ # end
166
+ # service = MyService.new # Uses SenroUsecaser.container
167
+ #
168
+ # @example With explicit container
169
+ # service = MyService.new(container: custom_container)
170
+ #
171
+ # @example Custom initialize with super
172
+ # class MyService
173
+ # extend SenroUsecaser::DependsOn
174
+ # depends_on :logger
175
+ # attr_reader :extra
176
+ #
177
+ # def initialize(extra:, container: nil)
178
+ # super(container: container)
179
+ # @extra = extra
180
+ # end
181
+ # end
182
+ #
183
+ #: (?container: Container?) -> void
184
+ def initialize(container: nil)
185
+ @_container = container || SenroUsecaser.container
186
+ @_dependencies = {} #: Hash[Symbol, untyped]
187
+ resolve_dependencies
188
+ end
189
+
190
+ # Resolves all declared dependencies from the container
191
+ #
192
+ # Call this in your initialize method after setting @_container and @_dependencies.
193
+ #
194
+ # @example
195
+ # def initialize(container:)
196
+ # @_container = container
197
+ # @_dependencies = {}
198
+ # resolve_dependencies
199
+ # end
200
+ #
201
+ #: () -> void
202
+ def resolve_dependencies
203
+ self.class.dependencies.each do |name| # steep:ignore NoMethod
204
+ @_dependencies[name] = resolve_from_container(name)
205
+ end
206
+ end
207
+
208
+ private
209
+
210
+ # Returns the effective namespace for dependency resolution
211
+ #
212
+ # Priority:
213
+ # 1. Explicitly declared namespace via `namespace :name`
214
+ # 2. Inferred namespace from module structure (if configured)
215
+ #
216
+ #: () -> (Symbol | String)?
217
+ def effective_namespace
218
+ declared = self.class.declared_namespace # steep:ignore NoMethod
219
+ return declared if declared
220
+ return nil unless SenroUsecaser.configuration.infer_namespace_from_module
221
+
222
+ infer_namespace_from_class
223
+ end
224
+
225
+ # Infers namespace from the class's module structure
226
+ #
227
+ # Converts CamelCase module names to snake_case and joins with "::"
228
+ # e.g., Admin::Orders::ProcessService -> "admin::orders"
229
+ #
230
+ #: () -> String?
231
+ def infer_namespace_from_class
232
+ class_name = self.class.name
233
+ return nil unless class_name
234
+
235
+ parts = class_name.split("::")
236
+ return nil if parts.length <= 1
237
+
238
+ module_parts = parts[0...-1] || [] #: Array[String]
239
+ return nil if module_parts.empty?
240
+
241
+ module_parts.map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase }.join("::")
242
+ end
243
+
244
+ # Resolves a single dependency from the container
245
+ #
246
+ #: (Symbol) -> untyped
247
+ def resolve_from_container(name)
248
+ ns = effective_namespace
249
+ if ns
250
+ @_container.resolve_in(ns, name)
251
+ else
252
+ @_container.resolve(name)
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
@@ -38,57 +38,18 @@ module SenroUsecaser
38
38
  # end
39
39
  # end
40
40
  class Hook
41
- class << self
42
- # Declares a dependency to be injected from the container
43
- #
44
- #: (Symbol, ?Class) -> void
45
- def depends_on(name, type = nil)
46
- dependencies << name unless dependencies.include?(name)
47
- dependency_types[name] = type if type
48
-
49
- define_method(name) do
50
- @_dependencies[name]
51
- end
52
- end
53
-
54
- # Returns the list of declared dependencies
55
- #
56
- #: () -> Array[Symbol]
57
- def dependencies
58
- @dependencies ||= []
59
- end
60
-
61
- # Returns the dependency type mapping
62
- #
63
- #: () -> Hash[Symbol, Class]
64
- def dependency_types
65
- @dependency_types ||= {}
66
- end
41
+ extend DependsOn
67
42
 
68
- # Sets or returns the namespace for dependency resolution
69
- #
70
- #: (?(Symbol | String)) -> (Symbol | String)?
71
- def namespace(name = nil)
72
- if name
73
- @hook_namespace = name
74
- else
75
- @hook_namespace
76
- end
77
- end
78
-
79
- # Alias for namespace() without arguments
43
+ class << self
44
+ # Alias for backward compatibility
80
45
  #
81
46
  #: () -> (Symbol | String)?
82
- def hook_namespace # rubocop:disable Style/TrivialAccessors
83
- @hook_namespace
84
- end
47
+ alias hook_namespace declared_namespace
85
48
 
86
49
  # @api private
87
50
  def inherited(subclass)
88
51
  super
89
- subclass.instance_variable_set(:@dependencies, dependencies.dup)
90
- subclass.instance_variable_set(:@dependency_types, dependency_types.dup)
91
- subclass.instance_variable_set(:@hook_namespace, @hook_namespace)
52
+ copy_depends_on_to(subclass)
92
53
  end
93
54
  end
94
55
 
@@ -127,54 +88,39 @@ module SenroUsecaser
127
88
  yield
128
89
  end
129
90
 
91
+ # Called when the UseCase fails
92
+ # Override in subclass to add failure handling or rollback logic
93
+ #
94
+ # @example Basic logging
95
+ # def on_failure(input, result)
96
+ # logger.error("Failed: #{result.errors.first&.message}")
97
+ # end
98
+ #
99
+ # @example Request retry
100
+ # def on_failure(input, result, context)
101
+ # if result.errors.first&.code == :network_error && context.attempt < 3
102
+ # context.retry!(wait: 2.0)
103
+ # end
104
+ # end
105
+ #
106
+ #: (untyped, Result[untyped], ?RetryContext?) -> void
107
+ def on_failure(input, result, context = nil)
108
+ # Override in subclass
109
+ end
110
+
130
111
  private
131
112
 
132
113
  # Returns the effective namespace for dependency resolution
114
+ # Overrides DependsOn::InstanceMethods to add use_case_namespace fallback
133
115
  #
134
116
  #: () -> (Symbol | String)?
135
117
  def effective_namespace
136
- return self.class.hook_namespace if self.class.hook_namespace
118
+ declared = self.class.declared_namespace
119
+ return declared if declared
137
120
  return @_use_case_namespace if @_use_case_namespace
138
121
  return nil unless SenroUsecaser.configuration.infer_namespace_from_module
139
122
 
140
123
  infer_namespace_from_class
141
124
  end
142
-
143
- # Infers namespace from the class's module structure
144
- #
145
- #: () -> String?
146
- def infer_namespace_from_class
147
- class_name = self.class.name
148
- return nil unless class_name
149
-
150
- parts = class_name.split("::")
151
- return nil if parts.length <= 1
152
-
153
- module_parts = parts[0...-1] || [] #: Array[String]
154
- return nil if module_parts.empty?
155
-
156
- module_parts.map { |part| part.gsub(/([a-z])([A-Z])/, '\1_\2').downcase }.join("::")
157
- end
158
-
159
- # Resolves dependencies from the container
160
- #
161
- #: () -> void
162
- def resolve_dependencies
163
- self.class.dependencies.each do |name|
164
- @_dependencies[name] = resolve_from_container(name)
165
- end
166
- end
167
-
168
- # Resolves a single dependency from the container
169
- #
170
- #: (Symbol) -> untyped
171
- def resolve_from_container(name)
172
- namespace = effective_namespace
173
- if namespace
174
- @_container.resolve_in(namespace, name)
175
- else
176
- @_container.resolve(name)
177
- end
178
- end
179
125
  end
180
126
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module SenroUsecaser
6
+ # Configuration for automatic retry behavior
7
+ #
8
+ # This class defines when and how retries should occur based on
9
+ # error codes or exception classes, with configurable backoff strategies.
10
+ #
11
+ # @example Basic retry configuration
12
+ # RetryConfiguration.new(
13
+ # matchers: [:network_error, Net::OpenTimeout],
14
+ # attempts: 3,
15
+ # wait: 1.0
16
+ # )
17
+ #
18
+ # @example With exponential backoff
19
+ # RetryConfiguration.new(
20
+ # matchers: [:rate_limited],
21
+ # attempts: 5,
22
+ # wait: 2.0,
23
+ # backoff: :exponential,
24
+ # max_wait: 60
25
+ # )
26
+ class RetryConfiguration
27
+ # Returns the list of error matchers (Symbols for error codes, Classes for exceptions)
28
+ #: () -> Array[(Symbol | Class)]
29
+ attr_reader :matchers
30
+
31
+ # Returns the maximum number of attempts
32
+ #: () -> Integer
33
+ attr_reader :attempts
34
+
35
+ # Returns the base wait time in seconds
36
+ #: () -> (Float | Integer)
37
+ attr_reader :wait
38
+
39
+ # Returns the backoff strategy (:fixed, :linear, :exponential)
40
+ #: () -> Symbol
41
+ attr_reader :backoff
42
+
43
+ # Returns the maximum wait time in seconds
44
+ #: () -> (Float | Integer)
45
+ attr_reader :max_wait
46
+
47
+ # Returns the jitter factor (0.0 to 1.0)
48
+ #: () -> (Float | Integer)
49
+ attr_reader :jitter
50
+
51
+ # Initializes a new retry configuration
52
+ #
53
+ # @param matchers [Array<Symbol, Class>] Error codes or exception classes to match
54
+ # @param attempts [Integer] Maximum number of attempts (default: 3)
55
+ # @param wait [Numeric] Base wait time in seconds (default: 0)
56
+ # @param backoff [Symbol] Backoff strategy: :fixed, :linear, or :exponential (default: :fixed)
57
+ # @param max_wait [Numeric] Maximum wait time in seconds (default: 3600)
58
+ # @param jitter [Numeric] Jitter factor 0.0-1.0 to randomize wait times (default: 0)
59
+ #
60
+ # rubocop:disable Metrics/ParameterLists
61
+ #: (matchers: Array[(Symbol | Class)], ?attempts: Integer, ?wait: (Float | Integer),
62
+ #: ?backoff: Symbol, ?max_wait: (Float | Integer)?, ?jitter: (Float | Integer)) -> void
63
+ def initialize(matchers:, attempts: 3, wait: 0, backoff: :fixed, max_wait: nil, jitter: 0)
64
+ # rubocop:enable Metrics/ParameterLists
65
+ @matchers = matchers
66
+ @attempts = attempts
67
+ @wait = wait
68
+ @backoff = backoff
69
+ @max_wait = max_wait || 3600
70
+ @jitter = jitter
71
+ end
72
+
73
+ # Checks if this configuration matches the given result
74
+ #
75
+ #: (Result[untyped]) -> bool
76
+ def matches?(result)
77
+ return false unless result.failure?
78
+
79
+ result.errors.any? { |error| matches_error?(error) }
80
+ end
81
+
82
+ # Calculates the wait time for the given attempt number
83
+ #
84
+ #: (Integer) -> Float
85
+ def calculate_wait(attempt)
86
+ base = calculate_base_wait(attempt)
87
+ base = [base, @max_wait].min
88
+ apply_jitter(base)
89
+ end
90
+
91
+ private
92
+
93
+ # Checks if an error matches any of the configured matchers
94
+ #
95
+ #: (Error) -> bool
96
+ def matches_error?(error)
97
+ @matchers.any? do |matcher|
98
+ case matcher
99
+ when Symbol
100
+ error.code == matcher
101
+ when Class
102
+ error.cause&.is_a?(matcher)
103
+ end
104
+ end
105
+ end
106
+
107
+ # Calculates the base wait time based on backoff strategy
108
+ #
109
+ #: (Integer) -> (Float | Integer)
110
+ def calculate_base_wait(attempt)
111
+ case @backoff
112
+ when :linear
113
+ @wait * attempt
114
+ when :exponential
115
+ @wait * (2**(attempt - 1))
116
+ else # :fixed or any other value
117
+ @wait
118
+ end
119
+ end
120
+
121
+ # Applies jitter to the wait time
122
+ #
123
+ #: ((Float | Integer)) -> Float
124
+ def apply_jitter(base)
125
+ return base.to_f if @jitter <= 0
126
+
127
+ jitter_amount = (rand * @jitter * base * 2) - (@jitter * base)
128
+ [base + jitter_amount, 0.0].max.to_f
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module SenroUsecaser
6
+ # Represents the context of a retry operation
7
+ #
8
+ # This class tracks the state of retry attempts including:
9
+ # - Current attempt number
10
+ # - Maximum attempts allowed
11
+ # - Elapsed time since first attempt
12
+ # - Whether a retry should occur
13
+ #
14
+ # @example Basic usage in on_failure hook
15
+ # on_failure do |input, result, context|
16
+ # if context.attempt < 3
17
+ # context.retry!(wait: 1.0)
18
+ # end
19
+ # end
20
+ #
21
+ # @example With modified input
22
+ # on_failure do |input, result, context|
23
+ # if result.errors.first&.code == :rate_limited
24
+ # context.retry!(input: input.with_reduced_batch_size, wait: 5.0)
25
+ # end
26
+ # end
27
+ class RetryContext
28
+ # Returns the current attempt number (1-indexed)
29
+ #: () -> Integer
30
+ attr_reader :attempt
31
+
32
+ # Returns the maximum number of attempts allowed
33
+ #: () -> Integer?
34
+ attr_reader :max_attempts
35
+
36
+ # Returns the time when the first attempt started
37
+ #: () -> Time
38
+ attr_reader :started_at
39
+
40
+ # Returns the error from the last failed attempt
41
+ #: () -> Error?
42
+ attr_reader :last_error
43
+
44
+ # Returns the input to use for the retry (nil means use original)
45
+ #: () -> untyped
46
+ attr_reader :retry_input
47
+
48
+ # Returns the wait time before retrying
49
+ #: () -> (Float | Integer)?
50
+ attr_reader :retry_wait
51
+
52
+ # Initializes a new retry context
53
+ #
54
+ #: (?max_attempts: Integer?) -> void
55
+ def initialize(max_attempts: nil)
56
+ @attempt = 1
57
+ @max_attempts = max_attempts
58
+ @started_at = Time.now
59
+ @last_error = nil
60
+ @should_retry = false
61
+ @retry_input = nil
62
+ @retry_wait = nil
63
+ end
64
+
65
+ # Returns true if this is a retry (attempt > 1)
66
+ #
67
+ #: () -> bool
68
+ def retried?
69
+ @attempt > 1
70
+ end
71
+
72
+ # Returns the elapsed time since the first attempt
73
+ #
74
+ #: () -> Float
75
+ def elapsed_time
76
+ Time.now - @started_at
77
+ end
78
+
79
+ # Returns true if max_attempts has been reached
80
+ #
81
+ #: () -> bool
82
+ def exhausted?
83
+ return false unless @max_attempts
84
+
85
+ @attempt >= @max_attempts
86
+ end
87
+
88
+ # Returns true if a retry has been requested
89
+ #
90
+ #: () -> bool
91
+ def should_retry?
92
+ @should_retry
93
+ end
94
+
95
+ # Requests a retry with optional modified input and wait time
96
+ #
97
+ # @example Retry with default settings
98
+ # context.retry!
99
+ #
100
+ # @example Retry with wait time
101
+ # context.retry!(wait: 2.0)
102
+ #
103
+ # @example Retry with modified input
104
+ # context.retry!(input: modified_input, wait: 1.0)
105
+ #
106
+ #: (?input: untyped, ?wait: (Float | Integer)?) -> void
107
+ def retry!(input: nil, wait: nil)
108
+ @should_retry = true
109
+ @retry_input = input
110
+ @retry_wait = wait
111
+ end
112
+
113
+ # Increments the attempt counter and resets retry state
114
+ # Called internally between retry attempts
115
+ #
116
+ #: (?last_error: Error?) -> void
117
+ def increment!(last_error: nil)
118
+ @attempt += 1
119
+ @last_error = last_error
120
+ reset_retry_state!
121
+ end
122
+
123
+ # Resets the retry request state
124
+ # Called internally after processing retry decision
125
+ #
126
+ #: () -> void
127
+ def reset_retry_state!
128
+ @should_retry = false
129
+ @retry_input = nil
130
+ @retry_wait = nil
131
+ end
132
+ end
133
+ end
@@ -4,5 +4,5 @@
4
4
 
5
5
  module SenroUsecaser
6
6
  #: String
7
- VERSION = "0.3.0"
7
+ VERSION = "0.4.1"
8
8
  end
@@ -8,6 +8,9 @@ require_relative "senro_usecaser/result"
8
8
  require_relative "senro_usecaser/container"
9
9
  require_relative "senro_usecaser/configuration"
10
10
  require_relative "senro_usecaser/provider"
11
+ require_relative "senro_usecaser/retry_context"
12
+ require_relative "senro_usecaser/retry_configuration"
13
+ require_relative "senro_usecaser/depends_on"
11
14
  require_relative "senro_usecaser/hook"
12
15
  require_relative "senro_usecaser/base"
13
16