activeinteractor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_model'
4
+
5
+ require 'active_interactor/version'
6
+
7
+ # Copyright (c) 2019 Aaron Allen
8
+ #
9
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ # of this software and associated documentation files (the "Software"), to deal
11
+ # in the Software without restriction, including without limitation the rights
12
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ # copies of the Software, and to permit persons to whom the Software is
14
+ # furnished to do so, subject to the following conditions:
15
+ #
16
+ # The above copyright notice and this permission notice shall be included in
17
+ # all copies or substantial portions of the Software.
18
+ #
19
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25
+ # THE SOFTWARE.
26
+ #
27
+ # @author Aaron Allen <hello@aaronmallen.me>
28
+ # @since 0.0.1
29
+ # @version 0.1
30
+ module ActiveInteractor
31
+ extend ActiveSupport::Autoload
32
+
33
+ autoload :Base
34
+ autoload :Configuration
35
+ autoload :Context
36
+ autoload :Interactor
37
+
38
+ class << self
39
+ # The ActiveInteractor configuration
40
+ # @return [ActiveInteractor::Configuration] the configuration instance
41
+ def configuration
42
+ @configuration ||= Configuration.new
43
+ end
44
+
45
+ # Configures the ActiveInteractor gem
46
+ #
47
+ # @example Configure ActiveInteractor
48
+ # ActiveInteractor.configure do |config|
49
+ # config.logger = Rails.logger
50
+ # end
51
+ #
52
+ # @yield [ActiveInteractor#configuration]
53
+ def configure
54
+ yield(configuration)
55
+ end
56
+
57
+ # The ActiveInteractor logger object
58
+ # @return [Logger] the configured logger instance
59
+ def logger
60
+ configuration.logger
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInteractor
4
+ # The Base Interactor class inherited by all interactors
5
+ #
6
+ # @author Aaron Allen <hello@aaronmallen.me>
7
+ # @since 0.0.1
8
+ # @version 0.1
9
+ class Base
10
+ include Interactor
11
+ # A new instance of {Base}
12
+ # @param context [Hash, nil] the properties of the context
13
+ # @return [ActiveInteractor::Base] a new instance of {Base}
14
+ def initialize(context = {})
15
+ @context = self.class.context_class.new(self, context)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInteractor
4
+ # The Configuration object for the ActiveInteractor gem
5
+ #
6
+ # @author Aaron Allen <hello@aaronmallen.me>
7
+ # @since 0.0.1
8
+ # @version 0.1
9
+ #
10
+ # @!attribute [rw] logger
11
+ # @return [Logger] an instance of Logger
12
+ class Configuration
13
+ # The default configuration options for {Configuration}
14
+ # @return [Hash{Symbol => *}]
15
+ DEFAULTS = {
16
+ logger: Logger.new(STDOUT)
17
+ }.freeze
18
+
19
+ attr_accessor :logger
20
+
21
+ # A new instance of {Configuration}
22
+ # @param options [Hash{Symbol => *}] the options to initialize the
23
+ # configuration with.
24
+ # @option options [Logger] :logger the logger ActiveInteractor should
25
+ # use for logging
26
+ # @return [ActiveInteractor::Configuration] a new instance of {Configuration}
27
+ def initialize(options = {})
28
+ options = DEFAULTS.merge(options.dup || {}).slice(*DEFAULTS.keys)
29
+ options.each_key do |attribute|
30
+ instance_variable_set("@#{attribute}", options[attribute])
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/class/attribute'
4
+ require 'ostruct'
5
+
6
+ module ActiveInteractor
7
+ # ActiveInteractor::Context module
8
+ #
9
+ # @author Aaron Allen <hello@aaronmallen.me>
10
+ # @since 0.0.1
11
+ # @version 0.1
12
+ module Context
13
+ # Raised when an interactor context fails
14
+ #
15
+ # @author Aaron Allen <hello@aaronmallen.me>
16
+ # @since 0.0.1
17
+ # @version 0.1
18
+ #
19
+ # @!attribute [r] context
20
+ # @return [Base] an instance of {Base}
21
+ class Failure < StandardError
22
+ attr_reader :context
23
+
24
+ # A new instance of {Failure}
25
+ # @param context [Hash] an instance of {Base}
26
+ # @return [Failure] a new instance of {Failure}
27
+ def initialize(context = {})
28
+ @context = context
29
+ super
30
+ end
31
+ end
32
+
33
+ # The base context class inherited by all {Interactor::Context} classes
34
+ #
35
+ # @author Aaron Allen <hello@aaronmallen.me>
36
+ # @since 0.0.1
37
+ # @version 0.1
38
+ class Base < OpenStruct
39
+ include ActiveModel::Validations
40
+
41
+ class_attribute :__default_attributes, instance_writer: false, default: []
42
+
43
+ # A new instance of {Base}
44
+ # @param interactor [ActiveInteractor::Base] an interactor instance
45
+ # @param attributes [Hash, nil] the attributes of the context
46
+ # @return [ActiveInteractor::Context::Base] a new instance of {Base}
47
+ def initialize(interactor, attributes = {})
48
+ copy_flags!(attributes)
49
+ @interactor = interactor
50
+ super(attributes)
51
+ end
52
+
53
+ class << self
54
+ # Attributes defined on the context class
55
+ #
56
+ # @example Get attributes defined on a context class
57
+ # MyInteractor::Context.attributes
58
+ # #=> [:first_name, :last_name]
59
+ #
60
+ # @return [Array<Symbol>] the defined attributes
61
+ def attributes
62
+ __default_attributes
63
+ .concat(_validate_callbacks.map(&:filter).map(&:attributes).flatten)
64
+ .flatten
65
+ .uniq
66
+ end
67
+
68
+ # Set attributes on a context class
69
+ # @param [Array<Symbol, String>] attributes
70
+ #
71
+ # @example Define attributes on a context class
72
+ # MyInteractor::Context.attributes = :first_name, :last_name
73
+ # #=> [:first_name, :last_name]
74
+ #
75
+ # @return [Array<Symbol>] the defined attributes
76
+ def attributes=(*attributes)
77
+ self.__default_attributes = self.attributes.concat(attributes.flatten.map(&:to_sym)).uniq
78
+ end
79
+ end
80
+
81
+ # Attributes defined on the instance
82
+ #
83
+ # @example Get attributes defined on an instance
84
+ # MyInteractor::Context.attributes = :first_name, :last_name
85
+ # #=> [:first_name, :last_name]
86
+ #
87
+ # context = MyInteractor::Context.new(first_name: 'Aaron', last_name: 'Allen')
88
+ # #=> <#MyInteractor::Context first_name='Aaron', last_name='Allen'>
89
+ #
90
+ # context.attributes
91
+ # #=> { first_name: 'Aaron', last_name: 'Allen' }
92
+ #
93
+ # @example Get attributes defined on an instance with unknown attribute
94
+ # MyInteractor::Context.attributes = :first_name, :last_name
95
+ # #=> [:first_name, :last_name]
96
+ #
97
+ # context = MyInteractor::Context.new(first_name: 'Aaron', last_name: 'Allen', unknown: 'unknown')
98
+ # #=> <#MyInteractor::Context first_name='Aaron', last_name='Allen', unknown='unknown'>
99
+ #
100
+ # context.attributes
101
+ # #=> { first_name: 'Aaron', last_name: 'Allen' }
102
+ #
103
+ # context.unknown
104
+ # #=> 'unknown'
105
+ #
106
+ # @return [Hash{Symbol => *}] the defined attributes and values
107
+ def attributes
108
+ self.class.attributes.each_with_object({}) do |attribute, hash|
109
+ hash[attribute] = self[attribute] if self[attribute]
110
+ end
111
+ end
112
+
113
+ # Track that an Interactor has been called. The {#called!} method
114
+ # is used by the interactor being invoked with this context. After an
115
+ # interactor is successfully called, the interactor instance is tracked in
116
+ # the context for the purpose of potential future rollback
117
+ #
118
+ # @return [Array<ActiveInteractor::Base>] all called interactors
119
+ def called!
120
+ _called << interactor
121
+ end
122
+
123
+ # Removes properties from the instance that are not
124
+ # explicitly defined in the context instance {#attributes}
125
+ #
126
+ # @example Clean an instance of Context with unknown attribute
127
+ # MyInteractor::Context.attributes = :first_name, :last_name
128
+ # #=> [:first_name, :last_name]
129
+ #
130
+ # context = MyInteractor::Context.new(first_name: 'Aaron', last_name: 'Allen', unknown: 'unknown')
131
+ # #=> <#MyInteractor::Context first_name='Aaron', last_name='Allen', unknown='unknown'>
132
+ #
133
+ # context.unknown
134
+ # #=> 'unknown'
135
+ #
136
+ # context.clean!
137
+ # #=> { unknown: 'unknown' }
138
+ #
139
+ # context.unknown
140
+ # #=> nil
141
+ #
142
+ # @return [Hash{Symbol => *}] the deleted attributes
143
+ def clean!
144
+ deleted = {}
145
+ return deleted if keys.empty?
146
+
147
+ keys.reject { |key| self.class.attributes.include?(key) }.each do |attribute|
148
+ deleted[attribute] = self[attribute] if self[attribute]
149
+ delete_field(key.to_s)
150
+ end
151
+ deleted
152
+ end
153
+
154
+ # Fail the context instance. Failing a context raises an error
155
+ # that may be rescued by the calling interactor. The context is also flagged
156
+ # as having failed
157
+ #
158
+ # @example Fail an interactor context
159
+ # interactor = MyInteractor.new(name: 'Aaron')
160
+ # #=> <#MyInteractor name='Aaron'>
161
+ #
162
+ # interactor.context.fail!
163
+ # #=> ActiveInteractor::Context::Failure: <#MyInteractor::Context name='Aaron'>
164
+ #
165
+ # @param errors [ActiveModel::Errors, Hash] errors to add to the context on failure
166
+ # @see https://api.rubyonrails.org/classes/ActiveModel/Errors.html ActiveModel::Errors
167
+ # @raise [Failure]
168
+ def fail!(errors = {})
169
+ self.errors.merge!(errors) unless errors.empty?
170
+ @_failed = true
171
+ raise Failure, self
172
+ end
173
+
174
+ # Whether the context instance has failed. By default, a new
175
+ # context is successful and only changes when explicitly failed
176
+ #
177
+ # @note The {#failure?} method is the inverse of the {#success?} method
178
+ #
179
+ # @example Check if a context has failed
180
+ # context = MyInteractor::Context.new
181
+ # #=> <#MyInteractor::Context>
182
+ #
183
+ # context.failure?
184
+ # false
185
+ #
186
+ # context.fail!
187
+ # #=> ActiveInteractor::Context::Failure: <#MyInteractor::Context>
188
+ #
189
+ # context.failure?
190
+ # #=> true
191
+ #
192
+ # @return [Boolean] `false` by default or `true` if failed
193
+ def failure?
194
+ @_failed || false
195
+ end
196
+ alias fail? failure?
197
+
198
+ # All keys of properties currently defined on the instance
199
+ #
200
+ # @example An instance of Context with unknown attribute
201
+ # MyInteractor::Context.attributes = :first_name, :last_name
202
+ # #=> [:first_name, :last_name]
203
+ #
204
+ # context = MyInteractor::Context.new(first_name: 'Aaron', last_name: 'Allen', unknown: 'unknown')
205
+ # #=> <#MyInteractor::Context first_name='Aaron', last_name='Allen', unknown='unknown'>
206
+ #
207
+ # context.keys
208
+ # #=> [:first_name, :last_name, :unknown]
209
+ #
210
+ # @return [Array<Symbol>] keys defined on the instance
211
+ def keys
212
+ each_pair.map { |pair| pair[0].to_sym }
213
+ end
214
+
215
+ # Attempt to call the interactor for missing validation callback methods
216
+ # @raise [NameError] if the method is not a validation callback or method
217
+ # does not exist on the interactor instance
218
+ def method_missing(name, *args, &block)
219
+ interactor.send(name, *args, &block) if validation_callback?(name)
220
+ super
221
+ end
222
+
223
+ # Attempt to call the interactor for missing validation callback methods
224
+ # @return [Boolean] `true` if method is a validation callback and exists
225
+ # on the interactor instance
226
+ def respond_to_missing?(name, include_private)
227
+ return false unless validation_callback?(name)
228
+
229
+ interactor.respond_to?(name, include_private)
230
+ end
231
+
232
+ # Roll back an interactor context. Any interactors to which this
233
+ # context has been passed and which have been successfully called are asked
234
+ # to roll themselves back by invoking their
235
+ # {ActiveInteractor::Interactor#rollback #rollback} instance methods.
236
+ #
237
+ # @example Rollback an interactor's context
238
+ # context = MyInteractor.perform(name: 'Aaron')
239
+ # #=> <#MyInteractor::Context name='Aaron'>
240
+ #
241
+ # context.rollback!
242
+ # #=> true
243
+ #
244
+ # context
245
+ # #=> <#MyInteractor::Context name='Aaron'>
246
+ #
247
+ # @return [Boolean] `true` if rolled back successfully or `false` if already
248
+ # rolled back
249
+ def rollback!
250
+ return false if @_rolled_back
251
+
252
+ _called.reverse_each(&:execute_rollback)
253
+ @_rolled_back = true
254
+ end
255
+
256
+ # Whether the context instance is successful. By default, a new
257
+ # context is successful and only changes when explicitly failed
258
+ #
259
+ # @note the {#success?} method is the inverse of the {#failure?} method
260
+ #
261
+ # @example Check if a context has failed
262
+ # context = MyInteractor::Context.new
263
+ # #=> <#MyInteractor::Context>
264
+ #
265
+ # context.success?
266
+ # true
267
+ #
268
+ # context.fail!
269
+ # #=> ActiveInteractor::Context::Failure: <#MyInteractor::Context>
270
+ #
271
+ # context.success?
272
+ # #=> false
273
+ #
274
+ # @return [Boolean] `true` by default or `false` if failed
275
+ def success?
276
+ !failure?
277
+ end
278
+ alias successful? success?
279
+
280
+ private
281
+
282
+ attr_reader :interactor
283
+
284
+ def copy_flags!(context)
285
+ @_called = context.send(:_called) if context.respond_to?(:_called, true)
286
+ @_failed = context.failure? if context.respond_to?(:failure?)
287
+ end
288
+
289
+ def _called
290
+ @_called ||= []
291
+ end
292
+
293
+ def validation_callback?(method_name)
294
+ _validate_callbacks.map(&:filter).include?(method_name)
295
+ end
296
+ end
297
+ end
298
+
299
+ Dir[File.expand_path('context/*.rb', __dir__)].each { |file| require file }
300
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInteractor
4
+ # Provides interactor methods to included classes
5
+ #
6
+ # @author Aaron Allen <hello@aaronmallen.me>
7
+ # @since 0.0.1
8
+ # @version 0.1
9
+ module Interactor
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ extend ClassMethods
14
+ include Callbacks
15
+ include Context
16
+ include Execution
17
+
18
+ private
19
+
20
+ attr_accessor :context
21
+ end
22
+
23
+ module ClassMethods
24
+ # Invoke an interactor. This is the primary public API method to an
25
+ # interactor.
26
+ #
27
+ # @example Run an interactor
28
+ # MyInteractor.perform(name: 'Aaron')
29
+ # #=> <#MyInteractor::Context name='Aaron'>
30
+ #
31
+ # @param context [Hash] properties to assign to the interactor context
32
+ # @return [ActiveInteractor::Context::Base] an instance of context
33
+ def perform(context = {})
34
+ new(context).execute_perform
35
+ end
36
+
37
+ # Invoke an Interactor. The {.perform!} method behaves identically to
38
+ # the {.perform} method with one notable exception. If the context is failed
39
+ # during invocation of the interactor, the {ActiveInteractor::Context::Failure}
40
+ # is raised.
41
+ #
42
+ # @example Run an interactor
43
+ # MyInteractor.perform!(name: 'Aaron')
44
+ # #=> <#MyInteractor::Context name='Aaron'>
45
+ #
46
+ # @param context [Hash] properties to assign to the interactor context
47
+ # @return [ActiveInteractor::Context::Base] an instance of context
48
+ def perform!(context = {})
49
+ new(context).execute_perform!
50
+ end
51
+ end
52
+
53
+ # Whether or not the context should fail when invalid
54
+ # this will return false if
55
+ # {Interactor::Callbacks::ClassMethods#allow_context_to_be_invalid}
56
+ # has been invoked on the class.
57
+ # @return [Boolean] `true` if the context should fail
58
+ # `false` if it should not.
59
+ def fail_on_invalid_context?
60
+ self.class.__fail_on_invalid_context
61
+ end
62
+
63
+ # Invoke an Interactor instance without any hooks, tracking, or rollback
64
+ # @abstract It is expected that the {#perform} method is overwritten
65
+ # for each interactor class.
66
+ def perform; end
67
+
68
+ # Reverse prior invocation of an Interactor instance.
69
+ # @abstract Any interactor class that requires undoing upon downstream
70
+ # failure is expected to overwrite the {#rollback} method.
71
+ def rollback; end
72
+
73
+ # Whether or not the context should be cleaned after {#perform}
74
+ # if {#skip_clean_context!} has not been invoked on the instance
75
+ # and {Interactor::Callbacks::ClassMethods#clean_context_on_completion}
76
+ # is invoked on the class this will return `true`.
77
+ #
78
+ # @return [Boolean] `true` if the context should be cleaned
79
+ # `false` if it should not be cleaned.
80
+ def should_clean_context?
81
+ @should_clean_context.nil? && self.class.__clean_after_perform
82
+ end
83
+
84
+ # Skip {ActiveInteractor::Context::Base#clean! #clean! on an interactor
85
+ # context that calls the {Callbacks.clean_context_on_completion} class method.
86
+ # This method is meant to be invoked by organizer interactors
87
+ # to ensure contexts are approriately passed between interactors.
88
+ #
89
+ # @return [Boolean] `true` if the context should be cleaned
90
+ # `false` if it should not.
91
+ def skip_clean_context!
92
+ @should_clean_context = false
93
+ end
94
+ end
95
+ end
96
+
97
+ Dir[File.expand_path('interactor/*.rb', __dir__)].each { |file| require file }